diff --git a/AGENTS.md b/AGENTS.md index 665b8c8..316a548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,19 +6,27 @@ The human readable introduction of this Swift Package is in [README.md](README.m - `Sources/` contains the Swift source code per target. - `Sources/Rainmaker/` contains the Swift source code for the main static library provided by this package. +- `Sources/Rainmaker/Server.swift` declares `Server` with the files, OCS and app features, while `Sources/Rainmaker/Server+Events.swift` and `Sources/Rainmaker/Server+Notes.swift` extend it with the event stream and the notes features respectively. The notes extension also holds the private helpers every notes feature is built on: the request factory for the notes API roots (`Server.notesAPIRoot`, and `Server.notesAttachmentAPIRoot` because older notes releases route attachments only below `v1.4`), the mapping of the notes app's statuses onto `RainmakerError` gated by its `X-Notes-API-Versions` header (read through `Extensions/HTTPURLResponse+notesAPIVersions.swift`), and the JSON decoding and encoding of payloads. The listing of changed notes reads its `Last-Modified`, `ETag` and chunk headers into `NoteChanges` through `Extensions/Date+httpDate.swift` and `Extensions/String+entityTag.swift`, the latter also quoting the entity tag a conditional request sends in `If-None-Match`. A chunked listing sends `chunkSize` and `chunkCursor` but never `category`, and `Sources/Rainmaker/NoteChunkPass.swift` is the actor `Server.noteChunks(changedSince:chunkSize:)` unfolds its stream from, one chunk per request, so that a chunk is only requested when the consumer asks for it and the request runs within the consumer's task. The summary listings (`Server.noteSummaries(changedSince:)`, its conditional and chunked counterparts and `Server.noteSummaryChunks(changedSince:chunkSize:)`) are the same requests with `exclude=content` added, never excluding `title`, because its presence is what tells a note sent in full from one reduced to its identifier. Both families share one private implementation, which is generic over the internal protocol `Sources/Rainmaker/NoteChangeSet.swift`: `NoteChanges` and `NoteSummaryChanges` conform to it through `Extensions/NoteChanges+NoteChangeSet.swift` and `Extensions/NoteSummaryChanges+NoteChangeSet.swift`, which also decide whether the content is excluded, the response DTO `NoteEntry` and `NoteChunkPass` are generic over the type of a note sent in full and of a chunk respectively, and the public methods of both families stay concrete and documented one by one. Creating, changing and deleting a note and changing the settings of the notes app are single requests as well, and a change of a note sends the entity tag it is based on in `If-Match`, normalized like `If-None-Match`, so that a note changed elsewhere surfaces as `RainmakerError.noteConflict(current:)` carrying the note the notes app sent along with its `412`. The attachment features live there as well and all go to `Server.notesAttachmentAPIRoot` with the path of the attachment as the `path` query parameter. Uploading and deleting go through the shared helpers, while retrieving an attachment into memory or into a file does not, because the notes app answers it without its version header and with a bare `404` for every failure, so `validateAttachmentRetrieval(_:)` maps its statuses instead. +- `Sources/Rainmaker/Requests/MultipartFormData.swift` writes the `multipart/form-data` body with a single file field that `Server.addAttachment(_:toNote:fileName:)` sends for either a local file or bytes in memory. The body is staged in a temporary file, copied through `Server.stagingBufferSize`, sent through an upload task and removed in a `defer`, so neither the file nor the body is held in memory. +- `Sources/Rainmaker/Requests/Models/` contains the internal `Encodable` data transfer objects for request bodies, the counterpart of `Sources/Rainmaker/Responses/Models` for what is sent rather than what is received. They carry the server's field names and leave out `nil` values, because the notes app only changes what a body names. `NoteCreationRequest` and `NoteUpdateRequest` are the bodies `Server.createNote(title:category:content:modification:isFavorite:)` and `Server.updateNote(_:title:category:content:modification:isFavorite:ifMatching:)` send, with moments converted to whole seconds through `Extensions/Date+wholeSecondsSince1970.swift`. `NotesSettingsUpdateRequest` is the body `Server.updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)` sends, for which leaving out `nil` matters even more, because the notes app resets a setting sent as `null` to its default. - `Sources/Rainmaker/Requests/Bodies` contains static HTTP bodies for requests sent to a Nextcloud server. For example the uniform XML document when retrieving information about a WebDAV resource from the server. The reasoning is simplicity by having a plain file, ease of maintenance by making it editable like a standard XML document and performance by not always assembling it programmatically. -- `Sources/Rainmaker/Extensions/` is for implementations of extensions of first-party or platform types. One source code file per extended type and added feature. -- `Sources/Rainmaker/Models` contains Swift source code for data models which are also publicly available types. They do not necessarily mirror the structure and types as returned by the server in responses. They are meant to be as elegant and plausible as possible from a Swift client developer perspective, not necessarily mirroring the server responses exactly. -- `Sources/Rainmaker/Responses/Models` contains Swift source code for data models which actually enable the use of Swift's `Decodable` for server response data. They are not meant to be exposed outside the Swift package module but only as an intermediate representation to simplify deserialization. -- `Sources/Rainmaker/Push/` contains the `notify_push` WebSocket transport and the coordinator behind `Server.events(_:)`, which prefers the WebSocket when the server advertises the capability and falls back to polling otherwise. The mockable WebSocket abstraction protocols (`WebSocketConnecting`, `WebSocketChannel`, `WebSocketFrame`) live alongside `Requesting` in `Sources/Rainmaker/Requests/`, mirroring how the HTTP session is abstracted. `PendingPing` (with its `PendingPingState`) is what `URLSessionWebSocketChannel.sendPing()` waits through: it resumes the waiting task exactly once, because `URLSessionWebSocketTask.sendPing(pongReceiveHandler:)` has been seen calling its handler more than once for one ping, concurrently, when a connection is torn down, and also never calling it at all, which is why the waiting task's cancellation competes for the same outcome. Neither URLSession's `receive()` nor its pong handler observes task cancellation, so `PushNotificationsConnection` ends a session by closing its channel rather than by cancelling its tasks, and bounds every ping with a pong timeout. -- `Sources/RainmakerCLI/` contains the Swift source code for the accompanying command line utility which enables the usage of the library in a terminal environment without any additional upstream project. +- `Sources/Rainmaker/Extensions/` is for implementations of extensions of first-party or platform types. One source code file per extended type and added feature. `URLComponents+encodedQueryItems.swift` is what `Server.makeAppRequest(for:method:queryItems:)` and `Server.makeOCSRequest(for:method:queryItems:)` set their query with, because `URLComponents.queryItems` leaves `+`, `&` and `=` unencoded and the server would read a `+` in a value as a space; `RequestFactoryTests` proves it. +- `Sources/Rainmaker/Models` contains Swift source code for data models which are also publicly available types. They do not necessarily mirror the structure and types as returned by the server in responses. They are meant to be as elegant and plausible as possible from a Swift client developer perspective, not necessarily mirroring the server responses exactly. Models a downstream project may want to fake, such as `Note`, `NotesSettings`, `LoginFlow`, `LoginResult` and `User`, offer a public memberwise initializer. `ShareType` is a struct wrapping the server's number with named constants rather than an enum, so that a kind of share a future server introduces still decodes, while `NoteMode` and `ServerEventTransport` are enums backed by the strings the server and the CLI use. `NoteSummary` is a `Note` without its content and without `hasError` and `errorType`, because the notes app only notices a failure to read a note while reading the content a summary listing asks it to leave out, and `NoteSummaryChanges` mirrors `NoteChanges` with summaries in place of notes. The `Notes` capability also compares releases of the notes app (`Notes.isAppVersion(atLeast:)`), because some of its behaviours, like where uploaded attachments land, are tied to a release rather than to the notes API version; `Tests/RainmakerTests/NotesCapabilityVersionTests.swift` covers that comparison. `NoteAttachment` and `NoteAttachmentFile` are the results of retrieving an attachment into memory and into a local file respectively, and like `ConversationAvatar` they carry the MIME type the server states but deliberately no file name. +- `Sources/Rainmaker/NoteContent/` contains pure helpers which need no `Server`, so that downstream apps such as Shortcuts actions do not reimplement conventions of the notes app and of the Nextcloud Text app: `NoteTitle` derives the title the notes app's web interface gives a note from its content and sanitizes a typed title, `NoteCategory` sanitizes a category, `NoteAttachmentReference` encodes, decodes and builds the Markdown references to attachments the way Text writes them, and `NoteAttachmentPath` resolves the path of an attachment relative to a note's category as the notes app does when serving it. They are public caseless enums used as namespaces, and the internal `NoteNameSanitizer` and `NoteMarkdownStripper` hold the rules the title and the category share and the removal of Markdown syntax before deriving a title. They work on Unicode scalars, because the notes app counts and matches code points, and the white space they recognize is the fixed list of the notes app's pattern matching rather than a Unicode property. They were re-implemented from observed behaviour and checked against it, never copied from the AGPL-licensed sources, and every pass runs in time proportional to the length of the content except the removal of emphasis, whose worst case, a line of ever shorter runs of delimiters, grows with the length of the line times its square root, so that pathological content cannot stall a caller. +- `Sources/Rainmaker/Responses/Models` contains Swift source code for data models which actually enable the use of Swift's `Decodable` for server response data. They are not meant to be exposed outside the Swift package module but only as an intermediate representation to simplify deserialization. `AttachmentUploadResponse` is the one the notes app answers an uploaded attachment with, which is reduced to the stored path it carries. `CurrentUserResponse` decodes only the identifier and the display name of the account details `Server.currentUser()` maps onto the public `User`, leaving out the many other details the server sends, so that a change to any of them cannot break the lookup. +- `Sources/Rainmaker/Push/` contains the `notify_push` WebSocket transport and the coordinator behind `Server.events(_:)`. With the default `ServerEventTransport.automatic` (`Sources/Rainmaker/Models/ServerEventTransport.swift`, chosen through `ServerEventOptions.transport`) the coordinator prefers the WebSocket when the server advertises the capability and falls back to polling otherwise, and it also polls for `rediscoverInterval` after `maximumAuthenticationAttempts` consecutive authentication rejections or `maximumConnectionFailures` consecutive connections which ended before authenticating, a connection which authenticated resetting the latter count. With `ServerEventTransport.polling`, and on watchOS where `ServerEventCoordinator.platformSupportsWebSocket` is `false`, it only polls and never requests the capabilities or opens a socket. `SessionOutcome.swift` is what one WebSocket session reduces to for those decisions and `PushTarget.swift` the endpoint and subjects derived from the capability, each in a file of its own beside `ServerEventCoordinator.swift`. The mockable WebSocket abstraction protocols (`WebSocketConnecting`, `WebSocketChannel`, `WebSocketFrame`) live alongside `Requesting` in `Sources/Rainmaker/Requests/`, mirroring how the HTTP session is abstracted. `PendingPing` (with its `PendingPingState`) is what `URLSessionWebSocketChannel.sendPing()` waits through: it resumes the waiting task exactly once, because `URLSessionWebSocketTask.sendPing(pongReceiveHandler:)` has been seen calling its handler more than once for one ping, concurrently, when a connection is torn down, and also never calling it at all, which is why the waiting task's cancellation competes for the same outcome. Neither URLSession's `receive()` nor its pong handler observes task cancellation, so `PushNotificationsConnection` ends a session by closing its channel rather than by cancelling its tasks, and bounds every ping with a pong timeout. +- `Sources/RainmakerCLI/` contains the Swift source code for the accompanying command line utility which enables the usage of the library in a terminal environment without any additional upstream project. Each top-level command lives in a file of its own in `Sources/RainmakerCLI/Commands/` and is registered alphabetically in `Rainmaker.swift`, such as `CurrentUser.swift` for `current-user` and `Poll.swift`, which polls a login flow through `Server.poll(_:)` until it is no longer pending. +- `Sources/RainmakerCLI/Commands/Notes/` contains the `notes` command group: `Notes.swift` declares the group with the helpers its subcommands share, and each subcommand lives in a file of its own as an extension of it (`Notes+List.swift`, the default subcommand which keeps `notes --changed-since` working and whose `--summaries` flag lists through the summary listings instead, `Notes+Get.swift`, `Notes+Create.swift`, `Notes+Update.swift` and `Notes+Delete.swift`). `Notes+Attachment.swift` declares the nested `notes attachment` group, whose `get`, `add` and `delete` subcommands live in `Notes+Attachment+Get.swift`, `Notes+Attachment+Add.swift` and `Notes+Attachment+Delete.swift`, with `get` streaming into a file through `Server.downloadAttachment(at:ofNote:to:force:)` when given `--output`, as `conversations avatar` writes its image. The settings of the notes app stay with the top-level `notes-settings` command in `Sources/RainmakerCLI/Commands/NotesSettings.swift`, which changes them when any setting is given and looks them up otherwise, and whose `--note-mode` option takes `NoteMode` values through `Sources/RainmakerCLI/NoteMode+ExpressibleByArgument.swift`, as `ActivitySort+ExpressibleByArgument.swift` does for the activities and `ServerEventTransport+ExpressibleByArgument.swift` for the `--transport` option of `watch`. The group shadows the library's `Notes` capability within the CLI module, and `Rainmaker.Notes` does not reach it either because the CLI's root command is called `Rainmaker`, so the CLI does not refer to that capability. - `Sources/RainmakerCLI/Commands/RecordFixtures.swift` and `Sources/RainmakerCLI/Fixtures/` implement the `record-fixtures` subcommand which automates the creation of test fixtures. It is macOS-only: it deploys ephemeral Nextcloud containers via the `NextcloudContainerManager` package, runs the test suite against them in recording mode to capture real responses into `Tests/RainmakerTests/Responses/`, and verifies the captures replay without a server. Containers are deployed with the apps a suite's fixtures depend on but which are not part of a Nextcloud installation, currently the notes app and the Talk app, which makes a recording run depend on the app store being reachable. All of its Docker-facing code is guarded with `#if os(macOS)` so the simulator builds compile the CLI without it. - `Sources/RainmakerTestServerTags/` is a small internal module holding `ServerVersion`, the single source of truth for the supported Nextcloud versions. Both the test target and the CLI's fixture recorder depend on it, so the version list is declared once. -- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package. +- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package. `Documentation.md` curates every public symbol into topics, `SynchronizingNotes.md` explains how a client keeps its own copy of the notes in step with the server and `UsingNotesFromAppIntents.md` how single actions such as those of Shortcuts use the notes features, both linked from the Notes topic. `CommandLineInterface.md` holds verbatim help listings of the CLI, which are regenerated with `COLUMNS=1000 swift run rainmaker-cli --help` so that they carry no line wrapping, whenever a command or its help text changes. - `Tests/` contains the automated tests per target. - `Tests/RainmakerTests/Responses/` contains static test fixtures which are the HTTP response bodies of actual server responses. They either are in JSON or XML format. -- `Tests/RainmakerTests/URLTestSession.swift` replays those fixtures during normal test runs, while `Tests/RainmakerTests/URLRecordingSession.swift` is its recording counterpart used by the `record-fixtures` subcommand. Both derive fixture paths through the shared `Tests/RainmakerTests/FixtureLocator.swift` so recording and replay can never diverge, and `Tests/RainmakerTests/FixtureCanonicalizer.swift` normalizes recorded responses (canonical host, redacted volatile fields) so fixtures stay stable. `Tests/RainmakerTests/ServerTesting.swift` selects between the two sessions based on the `RAINMAKER_FIXTURE_RECORD` environment variable. -- The `Server.events(_:)` tests do not use fixtures: they drive hand-authored WebSocket and request doubles (`Tests/RainmakerTests/MockWebSocketConnecting.swift`, `MockWebSocketChannel.swift`, `MockRequesting.swift`) so the WebSocket handshake, frame mapping, polling fallback, reconnection, and teardown are exercised deterministically without a server. `MockPingBehavior` scripts whether a mock channel answers pings, and `MockWebSocketClosure` records that a channel was closed and lets its receiving ignore task cancellation until then, as `URLSessionWebSocketTask` does, so the teardown is tested against the behaviour it actually has to cope with. +- `Tests/RainmakerTests/URLTestSession.swift` replays those fixtures during normal test runs, while `Tests/RainmakerTests/URLRecordingSession.swift` is its recording counterpart used by the `record-fixtures` subcommand. An upload whose response has a body, such as that of an attachment of a note, has the body recorded under the extension its `Accept` header maps to and replayed from there, while uploads answered without a body, like those over WebDAV, keep having no body file. Both derive fixture paths through the shared `Tests/RainmakerTests/FixtureLocator.swift` so recording and replay can never diverge, and `Tests/RainmakerTests/FixtureCanonicalizer.swift` normalizes recorded responses (canonical host, redacted volatile fields) so fixtures stay stable. Like its body rules, the response header fields it keeps depend on the request path: responses of the notes app additionally keep `ETag`, `Last-Modified`, `X-Notes-Chunk-Cursor` (all three with fixed canonical values) and `X-Notes-Chunk-Pending`, which every other response drops. `Tests/RainmakerTests/NoteChangesRequestTests.swift` covers reading those headers, the conditional request and the chunked listing with its stream with `MockRequesting`, because a replayed fixture neither carries live header values nor proves which request headers were sent and cannot answer a loop of chunks which share method and path, and it proves with `SuspendingRequesting.swift`, a double keeping a request in flight until its task is cancelled, that cancelling the stream's task cancels its request, while `EntityTagTests.swift` and `HTTPDateTests.swift` cover the two conversions. `Tests/RainmakerTests/NoteSummaryRequestTests.swift` covers the `exclude=content` query item of every summary listing, the decoding of summaries alongside identifiers, `nil` for `304` and the stream of chunks of summaries in the same way, while `NotesTests.fetchSummaries` and `NotesTests.fetchSummariesFirstChunk` record a summary listing and its first chunk. `Tests/RainmakerTests/SingleNoteRequestTests.swift` does the same for the lookup of a single note through `Server.note(_:)` and `Server.note(_:ifChangedFrom:)`, whose recorded counterparts in `NotesTests` take the identifier from a listing recorded in the same test, because the fixture of a lookup is filed under the identifier the server assigned. `Tests/RainmakerTests/NoteMutationTests.swift` records creating, changing and deleting notes, taking the identifier of every request after the creation from the recorded creation, as well as changing the settings of the notes app to the value they already have, while `Tests/RainmakerTests/NoteWritingRequestTests.swift` covers the request bodies, `If-Match`, the conflict and the other statuses of those calls with `MockRequesting`. `Tests/RainmakerTests/NoteAttachmentTests.swift` records adding, retrieving, downloading and deleting attachments, branching on the recorded capabilities because notes 5.0.2 and 6.1.0 store and delete them differently, and `Tests/RainmakerTests/NoteAttachmentRequestTests.swift` covers the encoding of their paths, the multipart body, the removal of the staged body and the statuses with `MockRequesting`, which answers downloads too and records the temporary files it hands them over in as well as the files uploads are sent from, so that the removal of both can be checked, and with `SuspendingRequesting`, which suspends uploads as it suspends data requests and downloads when asked to, so that cancelling a download is covered as well. `Tests/RainmakerTests/ServerTesting.swift` selects between the two sessions based on the `RAINMAKER_FIXTURE_RECORD` environment variable. +- `Tests/RainmakerTests/NoteTitleTests.swift`, `NoteCategoryTests.swift`, `NoteAttachmentReferenceTests.swift` and `NoteAttachmentPathTests.swift` cover the helpers in `Sources/Rainmaker/NoteContent/` with table-driven Swift Testing arguments and neither fixtures nor request doubles, comparing strings scalar by scalar so that a decomposed letter does not pass for its composed form. +- `Tests/RainmakerTests/LoginRequestTests.swift` covers how `Server.login()` and `Server.poll(_:)` map statuses and bodies onto their results with `MockRequesting`, including a pending flow as `nil`, a page which is not Nextcloud as `RainmakerError.responseDecodingFailed(reason:)` and the cancellation of a poll with `SuspendingRequesting`, while `LoginTests` and `PollTests` replay fixtures, those of `PollTests` hand-authored because a successful poll needs a flow completed in a browser. The tests of the deprecated `Server.poll(_:token:)` call it through `Serving`, whose requirement is deliberately not deprecated, so that they keep proving its unchanged behaviour without deprecation warnings. +- `Tests/RainmakerTests/CurrentUserTests.swift` records `Server.currentUser()` against the account `admin`, while `Tests/RainmakerTests/CurrentUserRequestTests.swift` covers its request, an account whose identifier differs from its login name, rejected credentials, undecodable bodies and the cancellation with `MockRequesting` and `SuspendingRequesting`. The account details carry the moments of the first and the last login and the quota, which `FixtureCanonicalizer` replaces with fixed numbers for responses below `/cloud/user`, and `FixtureCanonicalizerTests` proves that those rules apply nowhere else. +- The `Server.events(_:)` tests do not use fixtures: they drive hand-authored WebSocket and request doubles (`Tests/RainmakerTests/MockWebSocketConnecting.swift`, `MockWebSocketChannel.swift`, `MockRequesting.swift`) so the WebSocket handshake, frame mapping, polling fallback, reconnection, and teardown are exercised deterministically without a server. `MockWebSocketConnecting.openedChannelCount` proves how often a socket was opened, which is none at all with the polling transport and on watchOS, and the tests which need a socket are enabled only where `ServerEventCoordinator.platformSupportsWebSocket` is `true`, while one compiled only for watchOS proves that the automatic transport polls there. `MockPingBehavior` scripts whether a mock channel answers pings, and `MockWebSocketClosure` records that a channel was closed and lets its receiving ignore task cancellation until then, as `URLSessionWebSocketTask` does, so the teardown is tested against the behaviour it actually has to cope with. - `Tests/RainmakerTests/PendingPingTests.swift` drives `PendingPing` directly, a `URLSessionWebSocketTask` being impossible to substitute: it stores the pong handler a ping hands out and calls it the way the framework has been seen to, later, repeatedly, concurrently, or never. `Eventually.swift` is the polling wait those tests and the teardown tests use instead of `WithTimeout.swift`, which cannot bound an operation that ignores cancellation, and `LockedValue.swift` is the lock-protected box they share state with `@Sendable` closures through. - `Rainmaker.png` is a static artwork file for presentation on the web and can be ignored. @@ -46,15 +54,18 @@ The human readable introduction of this Swift Package is in [README.md](README.m - The fixtures are regenerated by the `record-fixtures` subcommand of `rainmaker-cli`, which requires macOS and a running Docker daemon. It is never run in continuous integration. - Run `swift run rainmaker-cli record-fixtures` to record every supported version, or scope it, e.g. `swift run rainmaker-cli record-fixtures --version 32.0.11 --filter ListingTests`. - The subcommand deploys a Nextcloud container per version, provisions a baseline, records each test against it (writing into `Tests/RainmakerTests/Responses/`), and finally replays the captures with `swift test` to prove they work without a server. Review the result with `git diff` before committing. -- Two apps the fixtures depend on are not part of a Nextcloud installation and are installed into every container from the app store, declared as `FixtureOrchestrator.installedApps`: the notes app for `NotesTests` and the Talk app (`spreed`) for `ConversationsTests`. That is why the recorded capabilities and navigation fixtures advertise `notes` and `spreed`, and it means recording needs the app store to be reachable. It also means the fixtures of those two suites track a release of their app rather than of the server, which is why their assertions rest on state `FixtureProvisioner` seeds rather than on whatever the app creates by itself. +- Each test is recorded by a `swift test` invocation of its own, so a full run takes long, about 25 minutes per version for the 80 recordable tests and well over an hour and a half for all four versions, and a run of a single version can be recorded with `--no-verify` and checked with `swift test` once every version is done. `--filter` is a plain substring of the `Suite/method` identifier, `--no-provision` skips the baseline for suites which need none, such as `CapabilitiesTests` and `CurrentUserTests`, and `--keep-running` leaves the container up for inspection. Build the tests with `swift build --build-tests` first: a test target which does not compile makes the recorder log `Recording 0 test(s)` and still exit successfully. Do not change Swift sources while a recording runs, because every test's invocation builds the package anew. +- Two apps the fixtures depend on are not part of a Nextcloud installation and are installed into every container from the app store, declared as `FixtureOrchestrator.installedApps`: the notes app for `NotesTests`, `NoteMutationTests` and `NoteAttachmentTests` and the Talk app (`spreed`) for `ConversationsTests`. That is why the recorded capabilities and navigation fixtures advertise `notes` and `spreed`, and it means recording needs the app store to be reachable. It also means the fixtures of those two suites track a release of their app rather than of the server, which is why their assertions rest on state `FixtureProvisioner` seeds rather than on whatever the app creates by itself. The app store currently hands Nextcloud 31 and 32 the notes app 5.0.2 and Nextcloud 33 and newer the notes app 6.1.0. A test whose expectations differ between those releases records the capabilities alongside its own requests and branches on `Notes.isAppVersion(atLeast:)` rather than on the server version, as `NotesTests.settings` does. - The chunked upload tests in `UploadTests` write files just above `Server.minimumChunkSize` (5 MiB), because the library raises smaller chunk sizes to it, and they pin the local modification date: the folder a chunked upload is staged in is named deterministically from the remote path, the size and the modification date of the file, which is what keeps its `MKCOL`, `PUT` and `MOVE` fixtures replayable. +- Fixtures of the notes app keep the headers `NoteChanges` is read from, with `ETag`, `Last-Modified` and `X-Notes-Chunk-Cursor` replaced by canonical values (see `FixtureCanonicalizer.notesHeaderFields`), so tests only assert that those values are present. Every recording of `NotesTests` also reassigns note identifiers and may reorder the listings, and so does every other recording in its own way: WebDAV listings come in another order and carry new upload times, the Talk conversations and login flows carry new tokens, the notes created by `NoteMutationTests` and `NoteAttachmentTests` get new identifiers and therefore new fixture folders, and the capabilities pick up the releases the app store currently hands out. Keep such changes only when a test needs them or the same fixture changed for a reason anyway, and revert them otherwise, so that a re-recording only shows what the server actually answers differently. A test which sends the same method and path twice, such as a conditional request answered with `304 Not Modified` after a full one, cannot be served by the fixture tree, because `FixtureLocator` ignores query strings and request headers; such tests use `MockRequesting` instead. +- Tests which change notes, such as those of `NoteMutationTests` and `NoteAttachmentTests`, delete what they create and never change the notes `FixtureProvisioner` seeds. The notes app 5.0.2 cannot delete attachments and leaves them behind when their note is deleted, so `NoteAttachmentTests` deletes them over WebDAV through the folder of the note's `path` there, which records a WebDAV `DELETE` with the random name the server chose. The response body of an upload is recorded when it is not empty, so the path the notes app answers an uploaded attachment with replays. `FixtureProvisioner.reset()` is a forced upload of the baseline, which removes notes and category folders a test left behind but does not restore a seeded note which is newer on the server than in the baseline. A note created without a modification moment records the moment of the recording as its `modified`, which is why `NoteMutationTests` passes a fixed one for every note it creates. The settings of the notes app are kept per account rather than in files, so `reset()` does not restore them either, and a test which changes a setting sets it back to the baseline value itself. - A handful of tests assume a server state different from the baseline. Those preconditions live in `FixtureProvisioner.applyPrecondition(forTest:)`. When the verification pass reports a test failing after recording, add or adjust its precondition there. - Adding a server version is a single edit: add a case to `ServerVersion` in the `RainmakerTestServerTags` module. Both the test parameterization and the recorder's default version list derive from it (or pass `--version` to scope a run). ## Documentation Instructions - Always check existing documentation comments for validity and update, if necessary. -- The published API reference is generated from public symbols only, so the full documentation of every `Server` method lives on the method itself in `Sources/Rainmaker/Server.swift` and `Sources/Rainmaker/Server+Events.swift`. The internal `Serving` protocol carries one-line abstracts only. +- The published API reference is generated from public symbols only, so the full documentation of every `Server` method lives on the method itself in `Sources/Rainmaker/Server.swift`, `Sources/Rainmaker/Server+Events.swift` and `Sources/Rainmaker/Server+Notes.swift`. The internal `Serving` protocol carries one-line abstracts only. - Whenever the files and folders within the repository change, update the "Repository Structure" section of this document accordingly. - Always check the `./README.md` for validity and update, if necessary. - Semantic versioning is used. Report on the impact in this regard after applying changes. diff --git a/README.md b/README.md index c60fb7b..ceaf0a5 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,17 @@ [![Tests](https://github.com/i2h3/rainmaker/actions/workflows/test.yml/badge.svg)](https://github.com/i2h3/rainmaker/actions/workflows/test.yml) [![REUSE](https://api.reuse.software/badge/github.com/i2h3/rainmaker)](https://api.reuse.software/info/github.com/i2h3/rainmaker) -A simple Swift library and CLI to access [Nextcloud](https://www.nextcloud.com) programmatically: files, notifications, activities, notes, collectives and Talk conversations. +A simple Swift library and CLI to access [Nextcloud](https://www.nextcloud.com) programmatically: files, notifications, activities, notes (including incremental and chunked synchronization, summaries without their text, conflict detection, attachments, and helpers which predict a note's title and category and reference attachments from its content the way the notes and Text apps do), collectives, Talk conversations and the current user. +The library keeps no state between calls and needs no synchronization engine, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of an account's data. For further information, see [the documentation which is built from the source code and deployed to GitHub pages](https://i2h3.github.io/rainmaker/). +## Requirements + +- Swift 6.2 or newer. +- iOS 15, macOS 12, tvOS 15, visionOS 1 or watchOS 8 or newer. +- A Nextcloud server. Every release is verified against the versions listed in `ServerVersion` in `Sources/RainmakerTestServerTags/`, currently Nextcloud 31, 32, 33 and 34. +- For the notes features, the notes app serving its API in version 1.4 or newer, which it does since release 4.12.3. Deleting attachments and keeping them in a folder per note require release 6.1.0 or newer. + ## Testing The test suite uses static fixtures committed under `Tests/RainmakerTests/Responses/` and isolated request doubles, so `swift test` needs no server and runs on every platform. diff --git a/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md b/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md index 81e9227..dc17866 100644 --- a/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md +++ b/Sources/Rainmaker/Documentation.docc/CommandLineInterface.md @@ -4,7 +4,7 @@ This package also provides a convenient executable to leverage the features of t ## Overview -You can get an overview by running the command line interface itself or any subcommand with the `--help` option in the package root directory like this: +You can get an overview by running the command line interface itself or any subcommand with the `--help` option in the package root directory like this, shown here and below without the line wrapping a terminal applies: ```plaintext $ swift run rainmaker-cli --help @@ -20,6 +20,7 @@ SUBCOMMANDS: collectives List the collectives of the authenticated user and their pages. Requires authentication and the server's collectives app. conversations List the Talk conversations of the authenticated user and retrieve their images. Requires authentication and the server's Talk app. create-directory Create a directory on the server. + current-user Show the identifier and the display name of the authenticated account. Requires authentication. delete Delete a file or directory from the server. delete-app-password Delete the app password currently used to authenticate, ending the session on the server side. download Download a file or directory from the server. @@ -28,17 +29,146 @@ SUBCOMMANDS: login Fetch the login flow information from a server. move Move or rename a remote file or directory on the server. navigation List the apps navigation entries advertised by a server. Requires authentication. - notes List the notes of the authenticated user. Requires authentication and the server's notes app. - notes-settings Show where the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app. + notes List, retrieve, create, change and delete the notes of the authenticated user and their attachments. Requires authentication and the server's notes app. + notes-settings Show or change where and how the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app. notifications List the notifications queued for the authenticated user. Requires authentication and the server's notifications app. poll Poll the status of a previously initiated login flow. trash Manage the server trash bin. upload Upload a file or directory to a folder on the server. - watch Observe server-side changes over notify_push (or polling when unavailable) and print each event. Runs until interrupted. + watch Observe server-side changes over notify_push (or polling when unavailable or asked to) and print each event. Runs until interrupted. + record-fixtures Record test fixtures by deploying Nextcloud containers and running the test suite against them. See 'rainmaker help ' for detailed help. ``` +## Notes + +The `notes` command is a group of subcommands, of which `list` is the default, so `notes --changed-since` keeps working as before. +Attachments are managed by the nested `notes attachment` group, and the settings of the notes app by the separate `notes-settings` command. +In the plain output, a listing of changes ends with a `#cursor` line while more chunks follow, an `#etag` line and a `#last-modified` line in whole seconds since the Unix epoch, which are what `--cursor`, `--if-none-match` and `--changed-since` take for the next listing, while `notes get` writes the note's `#etag` line to standard error so that standard output stays the text of the note. + +```plaintext +$ swift run rainmaker-cli notes --help +OVERVIEW: List, retrieve, create, change and delete the notes of the authenticated user and their attachments. Requires authentication and the server's notes app. + +USAGE: rainmaker notes + +OPTIONS: + -h, --help Show help information. + +SUBCOMMANDS: + list (default) List the notes of the authenticated user, all of them, those changed since a moment, or one chunk of those. + get Retrieve a single note of the authenticated user by its identifier. + create Create a note for the authenticated user and print its identifier, entity tag and title. + update Change a note of the authenticated user and print its identifier, entity tag and title. + delete Delete a note of the authenticated user. + attachment Retrieve, add and delete the files attached to a note of the authenticated user. + + See 'rainmaker help notes ' for detailed help. +``` + +```plaintext +$ swift run rainmaker-cli notes attachment --help +OVERVIEW: Retrieve, add and delete the files attached to a note of the authenticated user. + +USAGE: rainmaker notes attachment + +OPTIONS: + -h, --help Show help information. + +SUBCOMMANDS: + get Retrieve a file attached to a note of the authenticated user. + add Attach a local file to a note of the authenticated user and print the path the server stored it at. + delete Delete a file attached to a note of the authenticated user. Requires the notes app 6.1.0 or newer. + + See 'rainmaker help notes attachment ' for detailed help. +``` + +```plaintext +$ swift run rainmaker-cli notes-settings --help +OVERVIEW: Show or change where and how the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app. + +USAGE: rainmaker notes-settings [--user ] [--password ] [--output-format ] [--host ] [--notes-path ] [--file-suffix ] [--note-mode ] [--show-hidden-files ] [--load-recent-note-on-start-up ] + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + --output-format + In what form to render the output. (default: plain) + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + --notes-path + Store the notes in this folder, relative to the account's files, with an empty string for the root folder. Existing notes are not moved. + --file-suffix + Give the notes created from now on this file extension, such as '.md' or '.txt'. + --note-mode Open notes in the web interface in this mode. (values: rich, edit, preview) + --show-hidden-files + Whether files and folders whose names start with a dot are listed as notes and categories, 'true' or 'false'. Requires notes app 6.1.0 or newer. + --load-recent-note-on-start-up + Whether the web interface opens the most recently edited note when it starts, 'true' or 'false'. Requires notes app 6.1.0 or newer. + -h, --help Show help information. +``` + +## Current User + +```plaintext +$ swift run rainmaker-cli current-user --help +OVERVIEW: Show the identifier and the display name of the authenticated account. Requires authentication. + +USAGE: rainmaker current-user [--user ] [--password ] [--output-format ] [--host ] + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + --output-format + In what form to render the output. (default: plain) + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + -h, --help Show help information. +``` + +## Login + +`login` starts a login flow and prints the login page to open in a browser together with the poll address and the poll token, which `poll` takes to wait for the app password. +While the flow is pending, `poll` tries again every second, and any real failure, such as an unreachable server or an address which is not a login flow endpoint, ends it with that error. + +```plaintext +$ swift run rainmaker-cli poll --help +OVERVIEW: Poll the status of a previously initiated login flow. + +USAGE: rainmaker poll [--output-format ] [--tries ] + +ARGUMENTS: + The address which to poll for login flow status. + The token to identify the login flow. + +OPTIONS: + --output-format + In what form to render the output. (default: plain) + --tries How many times the endpoint should be polled before failing. (default: 300) + -h, --help Show help information. +``` + +## Observing Changes + +```plaintext +$ swift run rainmaker-cli watch --help +OVERVIEW: Observe server-side changes over notify_push (or polling when unavailable or asked to) and print each event. Runs until interrupted. + +USAGE: rainmaker watch [--user ] [--password ] [--host ] [--poll-interval ] [--transport ] [--listen-file-ids] + +OPTIONS: + -u, --user The user account name to authenticate with. Can also be set via RAINMAKER_USER environment variable. + -p, --password + The password to authenticate with. Can also be set via RAINMAKER_PASSWORD environment variable. + -h, --host The Nextcloud instance to connect to. Can also be set via RAINMAKER_HOST environment variable. + --poll-interval + The polling interval in seconds used when notify_push is unavailable or not used. (default: 30.0) + --transport How to learn about changes: 'automatic' prefers notify_push and polls when it is unavailable, 'polling' only polls. (values: automatic, polling; default: automatic) + --listen-file-ids Request per-file identifiers via notify_file_id. + -h, --help Show help information. +``` + ## Authentication You can provide credentials directly as command-line options: diff --git a/Sources/Rainmaker/Documentation.docc/Documentation.md b/Sources/Rainmaker/Documentation.docc/Documentation.md index 8e7c3fb..90d73fc 100644 --- a/Sources/Rainmaker/Documentation.docc/Documentation.md +++ b/Sources/Rainmaker/Documentation.docc/Documentation.md @@ -49,8 +49,10 @@ List, restore and permanently remove deleted items, which the server keeps in a ### Authentication Obtain an app password through the server's login flow and revoke it again once it is no longer needed. +``Server/poll(_:)`` returns `nil` while the user has not completed the flow and throws only for real failures, so a client polling it can tell an unreachable server from a pending flow, while the deprecated ``Server/poll(_:token:)`` throws in both cases. - ``Server/login()`` +- ``Server/poll(_:)`` - ``Server/poll(_:token:)`` - ``Server/deleteAppPassword()`` @@ -69,12 +71,14 @@ Obtain an app password through the server's login flow and revoke it again once ### Observing Changes Observe server-side changes over the `notify_push` WebSocket when available, falling back to polling otherwise, through a single stream of re-fetch hints. +The ``ServerEventTransport/polling`` transport polls only and never opens a socket, and watchOS always polls. - ``Server/events(_:)`` - ``Server/events(_:pollInterval:)`` - ``ServerEvent`` - ``ServerSubject`` - ``ServerEventOptions`` +- ``ServerEventTransport`` ### Activity Stream @@ -106,6 +110,7 @@ The server answers with an image for every user it knows, drawing one from their ``UserAvatar/isCustom`` is the only thing that distinguishes the two, and a client with a monogram style of its own has to consult it or it will draw over the server's placeholder rather than in place of it. Only two sizes are served, which is what ``AvatarSize`` models: the endpoint rounds any other value to one of them. Each fetch bypasses the local HTTP cache, and the endpoint publishes no version marker, so cache images on a bounded lifetime and key entries by server, account, user, size and appearance. +The avatar of the authenticated account is keyed by its identifier, which ``Server/currentUser()`` returns, rather than by the name it logs in with. - ``Server/userAvatar(_:size:darkTheme:)`` - ``UserAvatar`` @@ -114,15 +119,67 @@ Each fetch bypasses the local HTTP cache, and the endpoint publishes no version ### Notes Retrieve the notes of an account, either all of them at once or, for a client keeping its own copy, only those the server recorded a change for since a given moment. +Such an incremental retrieval also reports the server's own moment to continue from next time as ``NoteChanges/lastModified`` and an entity tag with which ``Server/notes(changedSince:ifChangedFrom:)`` learns without a transfer that nothing changed. +A client which must not hold every changed note at once retrieves them in chunks of a bounded size instead, one at a time or as a stream of the chunks of one pass, of which only the last one says which notes still exist. +A client which does not need the text of the notes lists them as summaries instead, which carry everything else and spare the transfer of every note's text, in the same incremental, conditional and chunked ways. +A single note is retrieved by its identifier, also conditionally on its entity tag, which spares the transfer of its content while it did not change. +Notes are created, changed and deleted one at a time, and a change can be made conditional on the entity tag of the copy it is based on, so that a note changed elsewhere in the meantime is reported as ``RainmakerError/noteConflict(current:)`` together with its current state rather than overwritten. Whether the app providing them is installed at all is advertised through the ``Notes`` capability, which matters more here than elsewhere because the notes app is not part of a Nextcloud installation, and which also reports whether it is new enough to be usable. -Notes are ordinary files, so ``NotesSettings`` says where to find them when reaching for them over WebDAV instead. - +Notes are ordinary files, so ``NotesSettings`` says where to find them when reaching for them over WebDAV instead, and ``Note/path`` says where exactly the file of each note is. +Those settings can be changed as well, which may change which notes the server lists, so a client keeping its own copy retrieves them anew afterwards. +Files such as images are attached to a note by uploading them, which returns the path a note's content references them by, relative to the folder of the note's category, and they are retrieved by that path into memory or streamed into a local file. +Some behaviours of the notes app are tied to its release rather than to its API version, which ``Notes/isAppVersion(atLeast:)`` and the helpers built on it, such as ``Notes/supportsAttachmentDeletion``, tell apart: only release 6.1.0 and newer keep the attachments of a note in a folder of their own and can delete them. +An attachment is retrieved bypassing the local HTTP cache, but a session still stores the response in its `URLCache`, so a session shared by several accounts or handling private files is best configured without one, see ``Server/init(address:password:user:session:webSocket:userAgent:)``. +An absent notes app is reported as ``RainmakerError/appUnavailable(app:)`` rather than as ``RainmakerError/notFound``, which is reserved for a note that does not exist, so a client keeping its own copy never mistakes a missing app for deleted notes, except when retrieving an attachment, which reports every failure including an absent app as ``RainmakerError/notFound``. +The two articles below put these calls together, one for a client keeping its own copy of the notes and one for single actions such as those of Shortcuts. + +- +- - ``Server/notes()`` - ``Server/notes(changedSince:)`` +- ``Server/notes(changedSince:ifChangedFrom:)`` +- ``Server/notes(changedSince:chunkSize:continuingAfter:)`` +- ``Server/notes(changedSince:chunkSize:ifChangedFrom:)`` +- ``Server/noteChunks(changedSince:chunkSize:)`` +- ``Server/noteSummaries(changedSince:)`` +- ``Server/noteSummaries(changedSince:ifChangedFrom:)`` +- ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)`` +- ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)`` +- ``Server/noteSummaryChunks(changedSince:chunkSize:)`` +- ``Server/note(_:)`` +- ``Server/note(_:ifChangedFrom:)`` +- ``Server/createNote(title:category:content:modification:isFavorite:)`` +- ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` +- ``Server/deleteNote(_:)`` +- ``Server/attachment(at:ofNote:)`` +- ``Server/downloadAttachment(at:ofNote:to:force:)`` +- ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` +- ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)`` +- ``Server/deleteAttachment(at:ofNote:)`` - ``Server/notesSettings()`` +- ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` - ``Note`` - ``NoteChanges`` +- ``NoteSummary`` +- ``NoteSummaryChanges`` +- ``ShareType`` - ``NotesSettings`` +- ``NoteMode`` +- ``NoteAttachment`` +- ``NoteAttachmentFile`` + +### Note Content + +Predict what the notes app makes of a note's title and category, and reference attachments from a note's Markdown content the way the Nextcloud Text app does, without asking the server. +These are pure functions which need neither a ``Server`` nor a connection, so a single action such as one of Shortcuts can prepare a request with them or interpret a note's content. +The notes API never derives a title from the content, so ``NoteTitle/derive(fromContent:)`` is how a client gives a note the title the web interface would give it. +The server still has the last word, so a caller adopts the ``Note/title`` and ``Note/category`` it returns, which may for example carry a number appended to avoid a clash. +An attachment added through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` only becomes part of the note once its content references it, and the Text app deletes files in the note's attachment folder which the content does not reference, so the reference has to be encoded exactly as ``NoteAttachmentReference/markdown(alt:path:)`` encodes it. + +- ``NoteTitle`` +- ``NoteCategory`` +- ``NoteAttachmentReference`` +- ``NoteAttachmentPath`` ### Collectives @@ -153,6 +210,16 @@ Keep separate cached images for each server, account, conversation token and app - ``ConversationType`` - ``ConversationAvatar`` +### Current User + +Look up the account a ``Server`` authenticates as. +Its identifier is not necessarily the name it logs in with, because a server can accept an email address or a login attribute of an LDAP directory as the login name, which is what ``Server/user`` and ``LoginResult/name`` hold. +``User/id`` is the identifier the server keys the account by, and it is what ``Server/userAvatar(_:size:darkTheme:)`` expects for the authenticated account. +The WebDAV paths of this library are still built from ``Server/user``, so they only work for accounts whose login name is their identifier. + +- ``Server/currentUser()`` +- ``User`` + ### Apps Navigation List the server apps, such as Files, Photos and Activity, which the server advertises to the authenticated user so that a client can surface them in its own navigation. @@ -177,6 +244,10 @@ List the server apps, such as Files, Photos and Activity, which the server adver ### Handling Errors +Every error this library raises on its own is a ``RainmakerError``. +The notes features map the statuses the notes app answers with onto dedicated cases, such as ``RainmakerError/readOnly``, ``RainmakerError/locked``, ``RainmakerError/insufficientStorage`` and ``RainmakerError/noteConflict(current:)``, but only when the response actually comes from that app, while anything a proxy or the server answers on its behalf stays ``RainmakerError/unexpectedStatus(code:)``. +An absent notes app is ``RainmakerError/appUnavailable(app:)``, an outdated one ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``, and a request the installed release does not offer at all, such as deleting an attachment before release 6.1.0, ``RainmakerError/methodNotAllowed``. + - ``RainmakerError`` ### Building Custom Requests @@ -184,6 +255,7 @@ List the server apps, such as Files, Photos and Activity, which the server adver If the built in features of Rainmaker do not suffice for your use case, you can use the following methods to build your own on top. This is useful for API endpoints not covered by Rainmaker. In example a third-party Nextcloud server app. +The names and values of query items are passed as they are and percent-encoded by the factories, including `+`, `&`, `=` and `#`, so a value such as `a+b` reaches the server as `a+b` rather than as `a b`. - ``Server/makeAppRequest(for:method:queryItems:)`` - ``Server/makeOCSRequest(for:method:queryItems:)`` diff --git a/Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md b/Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md new file mode 100644 index 0000000..5d13084 --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/SynchronizingNotes.md @@ -0,0 +1,177 @@ +# Synchronizing Notes + +Keep a local copy of the notes of an account in step with the server, transferring as little as possible and never losing a change made elsewhere. + +## Overview + +The notes app of a Nextcloud server keeps every note as an ordinary file and offers a REST API on top which reports what changed since a given moment. +A client keeping its own copy of the notes builds on four ideas: it asks for changes since the server's own moment of the previous call, it asks conditionally so that an unchanged account costs no transfer, it splits large transfers into chunks and derives deletions only from a complete answer, and it makes every change conditional on the copy it is based on. + +Each method mentioned here is a standalone call which needs nothing but a ``Server`` with credentials, so the same calls serve a full synchronization engine as well as a single action, see . + +### Check What the Server Offers + +The notes app is not part of a Nextcloud installation, so whether it is there and new enough is worth asking before anything else. +Every notes feature requires the notes API in version ``Notes/minimumAPIVersion`` or newer, which the notes app serves since release 4.12.3, and ``Notes/isSupported`` tells whether a server advertises it. +Some behaviours are tied to a release of the app rather than to its API version, which ``Notes/isAppVersion(atLeast:)`` and the helpers built on it tell apart. + +```swift +let capabilities = try await server.capabilities() + +guard let notes = try capabilities.get(Notes.self), notes.isSupported else { + return // The notes app is absent or too old for this library. +} + +let canDeleteAttachments = notes.supportsAttachmentDeletion // Release 6.1.0 or newer. +``` + +A notes app which goes away later is reported as ``RainmakerError/appUnavailable(app:)`` by every notes call except the retrieval of an attachment (see below), never as ``RainmakerError/notFound``, which is reserved for a note that does not exist. +Where the retrieval of an attachment reports ``RainmakerError/notFound``, the ``Notes`` capability tells whether the app is still there. +A client therefore never mistakes a missing app for an account whose notes were all deleted. + +### Retrieve Changes Since the Previous Call + +``Server/notes(changedSince:)`` returns every note the server recorded a change for at or after the given moment in full, and reduces every other note to its identifier. +Both halves of the ``NoteChanges`` together are the complete set of notes the account has, which is what makes deletions detectable. + +The moment is sent as the server's `pruneBefore` parameter and compared against the server's own record of when it noticed each note change, not against ``Note/modification``. +Pass `Date.distantPast` for the first call, which prunes nothing, and from then on the ``NoteChanges/lastModified`` of the previous result, which is the server's own clock at the time it started answering. +Never pass a note's ``Note/modification`` or the device's clock, because a note written long ago can still be one the server only noticed today. + +A note the server could not read is listed with ``Note/hasError`` set and a message about the failure in place of its text. +Such a note still exists, so its identifier counts when deletions are derived, but its content must not replace a good local copy. + +```swift +let changes = try await server.notes(changedSince: store.lastModified ?? .distantPast) + +for note in changes.changed where note.hasError == false { + store.upsert(note) +} + +// Every identifier the server returned, the unreadable notes included, is a note which still exists. +store.deleteAll(exceptFor: changes.changed.map(\.id) + changes.unchanged) + +// Remembered only once everything was applied, so an interrupted synchronization repeats rather than skips what it missed. +store.lastModified = changes.lastModified +store.entityTag = changes.entityTag +``` + +### Ask Conditionally + +A client polling the server passes the ``NoteChanges/entityTag`` and the ``NoteChanges/lastModified`` of the previous result to ``Server/notes(changedSince:ifChangedFrom:)``. +The server answers with an empty `304 Not Modified` when it would send exactly what it sent along with that tag, which the method returns as `nil`. +On `nil`, keep the previous moment and tag for the next call, which never skips a change, because the server keeps every note which changed in the very second the moment names. + +The server computes the tag from the body it would send, so the first call after a result which carried notes in full still receives a complete response, reducing those notes to their identifiers, and every further call answers `nil` until something changes. +`nil` deliberately differs from an empty ``NoteChanges``, which would claim that the account has no notes at all. + +A single note is refreshed the same way through ``Server/note(_:ifChangedFrom:)`` with its ``Note/entityTag``, which spares the transfer of its content while it did not change and reports a deleted note as ``RainmakerError/notFound`` whatever the tag. + +### Retrieve Changes in Chunks + +A client which must not hold every changed note at once, such as an extension or a watch app, retrieves a pass in chunks of a bounded size. +``Server/noteChunks(changedSince:chunkSize:)`` streams the chunks of one pass and requests a chunk only when the consumer asks for the next one, so a consumer which applies each chunk before it continues needs no more memory than one chunk takes. + +Only the last chunk of a pass, which ``NoteChanges/isComplete``, lists the identifiers of the notes the earlier chunks did not send in full, so deletions are derived from it alone and never from what a pass collected along the way. +Every chunk of one pass carries the same ``NoteChanges/lastModified``, which is remembered only once the last chunk was applied. + +```swift +var lastChunk: NoteChanges? + +for try await chunk in server.noteChunks(changedSince: store.lastModified ?? .distantPast, chunkSize: 50) { + store.upsert(chunk.changed.filter { $0.hasError == false }) + lastChunk = chunk +} + +// A stream which ended early, for example because its task was cancelled, did not deliver a complete pass. +guard let lastChunk, lastChunk.isComplete else { + return +} + +store.deleteAll(exceptFor: lastChunk.changed.map(\.id) + lastChunk.unchanged) +store.lastModified = lastChunk.lastModified +store.entityTag = lastChunk.entityTag +``` + +A pass interrupted, for example because the system suspended the app, continues later through ``Server/notes(changedSince:chunkSize:continuingAfter:)`` with the ``NoteChanges/chunkCursor`` of the last chunk applied and the same moment as before. +The first chunk of a new pass can be asked for conditionally through ``Server/notes(changedSince:chunkSize:ifChangedFrom:)`` with the tag of the last chunk of the previous pass. +The notes API also offers to narrow a listing down to a category, which this library never asks for, because that filter narrows the identifiers of the last chunk as well and would make every note outside the category look deleted. + +### Leave Out the Text With Summaries + +A client which lists notes but keeps no copy of their text, for example to index titles or to show a list, asks for summaries instead. +``Server/noteSummaries(changedSince:)``, ``Server/noteSummaries(changedSince:ifChangedFrom:)``, ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)``, ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)`` and ``Server/noteSummaryChunks(changedSince:chunkSize:)`` mirror the listings above and return ``NoteSummaryChanges`` with ``NoteSummary`` values, so the text of the notes is never downloaded. + +The server only notices that it cannot read a note while it reads the text, which a summary listing asks it to skip, so a ``NoteSummary`` has no counterpart to ``Note/hasError``. +``NoteSummary/entityTag`` is the same tag ``Note/entityTag`` carries, so the text of a single note can be fetched when it is needed through ``Server/note(_:)`` or ``Server/note(_:ifChangedFrom:)``, and a change can be based on the summary through `ifMatching`. +The entity tag of a whole response is computed from its body, which holds no text in a summary listing, so keep the response tags of summary listings and of full listings apart: one only matches the other while neither response sends a note in full. + +### Change Notes Without Losing Changes Made Elsewhere + +``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` sends only the values it is given, and with the ``Note/entityTag`` of the copy the change is based on in `ifMatching` the server only applies it while that is still the note's tag. +Otherwise nothing is changed and the call throws ``RainmakerError/noteConflict(current:)``, which carries the note as the server has it now, so a client merges its change into that note and retries with the current tag without a further request. + +```swift +do { + let updated = try await server.updateNote(local.id, content: local.content, ifMatching: local.entityTag) + store.upsert(updated) +} catch let RainmakerError.noteConflict(current: current) { + let merged = merge(local, into: current) + let updated = try await server.updateNote(current.id, content: merged.content, ifMatching: current.entityTag) + store.upsert(updated) +} +``` + +The server applies the values of one change one after the other, so a change which fails halfway may leave the earlier values applied, and ``Server/note(_:)`` tells what the note is now. +The server sanitizes titles and categories, which become file and folder names, so adopt the ``Note/title``, ``Note/category`` and ``Note/path`` it returns, which ``NoteTitle/sanitize(_:)`` and ``NoteCategory/sanitize(_:)`` predict. + +``Server/createNote(title:category:content:modification:isFavorite:)`` is not idempotent: every call which reaches the server creates another note, and the server numbers a title already taken rather than refuse it. +A call whose response was lost may have created the note all the same, so before repeating it, list the changes since the moment before the attempt and look for the note. +Passing the moment the note was written offline as `modification` keeps it as ``Note/modification``. + +``Server/deleteNote(_:)`` cannot be made conditional, because the server checks no entity tag when it deletes, and a ``RainmakerError/notFound`` in answer to a deletion means that the note is gone already. + +### Work With Attachments + +Files such as images are attached to a note through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` or ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)``, which return the path the server stored the file at, relative to the folder of the note's category. +They are retrieved by that path through ``Server/attachment(at:ofNote:)`` into memory or ``Server/downloadAttachment(at:ofNote:to:force:)`` into a local file, and deleted through ``Server/deleteAttachment(at:ofNote:)``. +These requests go to the `v1.4` segment of the notes API rather than to `v1`, because releases of the notes app before 6.1.0 route attachments only there. + +Where the file lands depends on the release of the notes app, see ``Notes/storesAttachmentsPerNote``: + +- Release 6.1.0 and newer store it in a folder `.attachments.` next to the note, under the given name with a number appended when it is taken, move that folder along when the note moves and delete it with the note. +- Older releases store it right next to the note under a random name, leave it behind when the note is deleted and cannot delete it through the API at all, which ``Server/deleteAttachment(at:ofNote:)`` reports as ``RainmakerError/methodNotAllowed`` and ``Notes/supportsAttachmentDeletion`` tells in advance. + +Adding an attachment does not change the note: it becomes part of the note only once the note's content references it. +The Nextcloud Text app, which edits Markdown notes in the web interface, encodes each component of such a reference as JavaScript's `encodeURIComponent` does and additionally encodes `!`, `'`, `(`, `)` and `*`. +A background job of the Text app deletes files in the `.attachments.` folder of a Markdown note which its content does not reference in exactly that form, so a reference built any other way can cost the file. +``NoteAttachmentReference/markdown(alt:path:)`` builds the reference the way Text does, ``NoteAttachmentReference/decode(_:)`` turns a reference back into the path the server takes, and ``NoteAttachmentReference/sanitizeFileName(_:)`` removes the invisible characters from a file name before upload which Text leaves out of references. + +The retrieval of an attachment is the one notes request the app answers without its version header, and it reports every failure with a bare `404`, so ``RainmakerError/notFound`` there may also mean an absent notes app. +The notes app offers no way to list the attachments of a note, but they are ordinary files, which ``Server/enumerate(at:recursively:)->[Item]`` lists in the folder which contains the file ``Note/path`` names, or in its `.attachments.` subfolder on releases which keep attachments per note, see ``Notes/storesAttachmentsPerNote``. + +### Treat Changed Settings as a New Set of Notes + +``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` can change which notes the server lists, for example by pointing it at another folder or by listing hidden files. +A client keeping its own copy retrieves the notes anew afterwards, starting from `Date.distantPast`. +The settings belong to the account rather than to a client, so another client may change them as well, which a client notices by comparing what ``Server/notesSettings()`` returns with what it saw before. +The settings ``NotesSettings/showsHiddenFiles`` and ``NotesSettings/loadsRecentNoteOnStartUp`` exist since release 6.1.0 of the notes app and are `nil` on older releases. + +### Configure the Session for Several Accounts + +Every request authenticates itself and refuses cookies, so one session can serve the ``Server`` objects of several accounts. +The notes requests bypass the local HTTP cache, but a session still stores their responses in its `URLCache`, which is keyed by URL alone while the same path of an attachment can stand for different files of different accounts. +A session shared by several accounts, or one which handles private notes, should therefore keep no cache at all, as ``Server/init(address:password:user:session:webSocket:userAgent:)`` describes: + +```swift +let configuration = URLSessionConfiguration.ephemeral +configuration.urlCache = nil +configuration.httpCookieStorage = nil +configuration.httpShouldSetCookies = false + +let session = URLSession(configuration: configuration) +let work = Server(address: workAddress, password: workPassword, user: workUser, session: session) +let personal = Server(address: personalAddress, password: personalPassword, user: personalUser, session: session) +``` + +Keep the moment, the entity tags and the local copy apart per server and account as well, because neither a moment nor a tag means anything to another account. diff --git a/Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md b/Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md new file mode 100644 index 0000000..1a1ed0d --- /dev/null +++ b/Sources/Rainmaker/Documentation.docc/UsingNotesFromAppIntents.md @@ -0,0 +1,160 @@ +# Using Notes From App Intents + +Build actions for Shortcuts, Siri and widgets on the notes features, each of which is a standalone call that needs no synchronization engine. + +## Overview + +An App Intent runs one action and ends: it creates a note from some text, appends to a note, attaches an image or looks something up. +Every notes method of ``Server`` suits that, because each one is a single request, or a short fixed sequence of requests, which needs nothing but a ``Server`` with credentials and keeps no state between calls. +Nothing has to be synchronized first, and nothing has to be torn down afterwards. + +The intents below assume a function `makeServer()` of the app's own which reads the address and the credentials of the account from the app's keychain and creates a ``Server`` with a session configured as described in . + +### Check the Notes App Before Changing Anything + +A release of the notes app older than ``Notes/minimumAPIVersion`` still carries out a creation, a change or a deletion it is asked for, and only its answer reveals that it is too old, so ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` from ``Server/createNote(title:category:content:modification:isFavorite:)``, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``, ``Server/deleteNote(_:)`` or ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` means that the change has already been made. +An intent which changes something without retrieving anything first therefore checks the ``Notes`` capability, so that it neither changes data on such a server nor tells the user it failed after it did, which would invite running it again and creating a duplicate. + +```swift +func requireUsableNotesApp(on server: Server) async throws { + guard try await server.capabilities().get(Notes.self)?.isSupported == true else { + throw NotesIntentError.notesAppUnusable + } +} +``` + +An intent which retrieves the note first, as the one appending to a note below does, needs no such check, because the retrieval changes nothing and already fails with ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` on such a server. + +### Create a Note From Text + +The notes API never derives a title from the content, so an intent which is only handed some text derives the title the notes app's web interface would give it through ``NoteTitle/derive(fromContent:)``. +A text without anything usable as a title derives an empty title, for which the server substitutes its default title. + +```swift +struct CreateNoteIntent: AppIntent { + static let title: LocalizedStringResource = "Create Note" + + @Parameter(title: "Text") + var text: String + + func perform() async throws -> some IntentResult & ReturnsValue { + let server = try makeServer() + try await requireUsableNotesApp(on: server) + let note = try await server.createNote(title: NoteTitle.derive(fromContent: text), content: text) + + return .result(value: note.title) + } +} +``` + +The server sanitizes the title and numbers one already taken, so the intent reports the ``Note/title`` it returns rather than the one it sent. +Creating a note is not idempotent: when the response was lost, the note may have been created all the same, so an intent does not simply repeat the call. +Checking the ``Notes`` capability first costs a request, but it is what keeps an outdated notes app from creating the note while the intent reports a failure. + +### Append to a Note + +Appending is a change based on the note's current content, so the intent makes it conditional on the ``Note/entityTag`` of the copy it appended to. +When the note changed elsewhere in the meantime, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` changes nothing and throws ``RainmakerError/noteConflict(current:)`` with the note as the server has it now, so the intent appends to that one and tries again without retrieving it first. + +```swift +func append(_ text: String, toNote id: Int, on server: Server) async throws -> Note { + var note = try await server.note(id) + + for _ in 0 ..< 3 { + // A note the server could not read carries a message about the failure in place of its text, which must not be written back. + guard note.hasError == false else { + throw AppendError.unreadable + } + + do { + return try await server.updateNote(note.id, content: note.content + "\n" + text, ifMatching: note.entityTag) + } catch let RainmakerError.noteConflict(current: current) { + note = current + } + } + + throw AppendError.keepsChanging +} +``` + +Without `ifMatching`, the last writer wins, and text typed into the note in the web interface while the intent ran would be lost. + +### Add an Image to a Note + +An image is uploaded as an attachment first and then referenced from the note's content, because adding an attachment does not change the note. +The reference has to be encoded exactly as the Nextcloud Text app encodes it, or a background job of that app deletes the file as unreferenced, which ``NoteAttachmentReference/markdown(alt:path:)`` takes care of. + +```swift +struct AddImageIntent: AppIntent { + static let title: LocalizedStringResource = "Add Image to Note" + + @Parameter(title: "Note Identifier") + var noteId: Int + + @Parameter(title: "Image") + var image: IntentFile + + func perform() async throws -> some IntentResult { + let server = try makeServer() + let name = NoteAttachmentReference.sanitizeFileName(image.filename) + let path = try await server.addAttachment(image.data, toNote: noteId, fileName: name) + + _ = try await append(NoteAttachmentReference.markdown(alt: name, path: path), toNote: noteId, on: server) + + return .result() + } +} +``` + +``NoteAttachmentReference/sanitizeFileName(_:)`` removes the invisible characters Text leaves out of references, so that the stored name matches the name the reference decodes to. +The path the server returns depends on the release of the notes app, see ``Notes/storesAttachmentsPerNote``, and the reference works with either form. +Like creating a note, adding an attachment is not idempotent, which is why a conflict only repeats the append above and never the upload. +A large file which already is on disk is better attached through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)``, which streams it instead of holding it in memory. + +### Bound the Time an Intent Takes + +Every call runs within the task which awaits it, so cancelling that task, as the system does with an intent it ends, cancels the request in flight. +The streams ``Server/noteChunks(changedSince:chunkSize:)`` and ``Server/noteSummaryChunks(changedSince:chunkSize:)`` request each chunk within the consuming task as well, and a stream which ended early did not deliver a complete pass, which its last chunk's `isComplete` tells. + +The time a request may take is a property of the session the ``Server`` was created with, so an app gives its intents a session with timeouts short enough for an action someone waits for. +A session used from an intent also keeps no cache, because an intent runs in a process which may handle several accounts and private notes: + +```swift +func makeServer() throws -> Server { + let account = try AccountStore.current() + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 30 + + return Server(address: account.address, password: account.appPassword, user: account.loginName, session: URLSession(configuration: configuration)) +} +``` + +A request which times out throws the `URLError` the session reports, which an intent surfaces like any other error. +The errors worth a message of their own are ``RainmakerError/appUnavailable(app:)`` and ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``, which mean that the server lacks a notes app this library can use, although a change the intent asked for may have been made all the same when it did not check the ``Notes`` capability first, see , and ``RainmakerError/notFound``, which means that the note was deleted, or for an attachment that it or its note is missing, where retrieving an attachment also reports an absent notes app this way. +A success response which did not come from the notes app at all, such as the login page of an authenticating proxy, is reported as ``RainmakerError/responseDecodingFailed(reason:)``. + +### Index Notes Without Their Text + +An intent or a background task which only needs the titles of the notes, for example to offer them as suggestions of an `EntityQuery` or to index them for Spotlight, lists them as summaries, so the text of every note is never downloaded. +``Server/noteSummaryChunks(changedSince:chunkSize:)`` keeps the memory a pass takes bounded by the chunk size, which suits an extension with little memory to spare. + +```swift +for try await chunk in server.noteSummaryChunks(changedSince: .distantPast, chunkSize: 200) { + let items = chunk.changed.map { summary in + let attributes = CSSearchableItemAttributeSet(contentType: .text) + attributes.title = summary.title + attributes.contentModificationDate = summary.modification + + return CSSearchableItem(uniqueIdentifier: String(summary.id), domainIdentifier: summary.category, attributeSet: attributes) + } + + try await CSSearchableIndex.default().indexSearchableItems(items) +} +``` + +A note picked from those suggestions is retrieved in full through ``Server/note(_:)`` when the intent runs, and ``NoteSummary/entityTag`` matches ``Note/entityTag``, so a summary is as good a basis for a conditional change as the note itself. +A client which keeps such an index up to date over time follows rather than listing everything again. diff --git a/Sources/Rainmaker/Extensions/Date+httpDate.swift b/Sources/Rainmaker/Extensions/Date+httpDate.swift new file mode 100644 index 0000000..cc76f43 --- /dev/null +++ b/Sources/Rainmaker/Extensions/Date+httpDate.swift @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension Date { + /// + /// Create a date from an HTTP date such as `"Sat, 01 Jan 2000 00:00:00 GMT"`, or fail when the string is not one. + /// + /// This is the format of the `Last-Modified` header the notes app sends with ``Server/notes(changedSince:)``, which becomes ``NoteChanges/lastModified``, and of the `getlastmodified` property WebDAV reports for every ``Item``, which is why the response parsing of both shares it. + /// The formatter is pinned to the `en_US_POSIX` locale, so neither the language nor the calendar of the device can change how the English day and month names are read, and its zone pattern also accepts the literal `GMT` every such date ends with. + /// + /// A new formatter is built on every call rather than kept in a static property, because `DateFormatter` is not `Sendable` and the Foundation format style made for HTTP dates requires operating system releases newer than this package supports. + /// + /// - Parameters: + /// - string: The HTTP date to read. + /// + init?(httpDate string: String) { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "E, dd MMM yyyy HH:mm:ss Z" + + guard let date = formatter.date(from: string) else { + return nil + } + + self = date + } +} diff --git a/Sources/Rainmaker/Extensions/FileManager+assertNotDirectory.swift b/Sources/Rainmaker/Extensions/FileManager+assertNotDirectory.swift new file mode 100644 index 0000000..cf9ba72 --- /dev/null +++ b/Sources/Rainmaker/Extensions/FileManager+assertNotDirectory.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension FileManager { + /// + /// Throw ``RainmakerError/fileAlreadyExists(_:)`` when a directory exists at the given location, which is what ``Server/downloadAttachment(at:ofNote:to:force:)`` checks before it puts a file in place. + /// + /// Unlike `assertFileDoesNotExist(at:)`, this passes for a regular file, so a caller which is allowed to replace a file can still refuse a directory, which `replaceItemAt(_:withItemAt:backupItemName:options:)` would otherwise delete together with everything in it. + /// + /// - Parameters: + /// - location: The local location to check. + /// + func assertNotDirectory(at location: URL) throws { + var isDirectory: ObjCBool = false + + if fileExists(atPath: location.compatibilityPath(percentEncoded: false), isDirectory: &isDirectory), isDirectory.boolValue { + throw RainmakerError.fileAlreadyExists(location) + } + } +} diff --git a/Sources/Rainmaker/Extensions/HTTPURLResponse+notesAPIVersions.swift b/Sources/Rainmaker/Extensions/HTTPURLResponse+notesAPIVersions.swift new file mode 100644 index 0000000..3f7786b --- /dev/null +++ b/Sources/Rainmaker/Extensions/HTTPURLResponse+notesAPIVersions.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension HTTPURLResponse { + /// + /// The versions of the notes API the responding notes app advertises in its `X-Notes-API-Versions` header, e.g. `["0.2", "1.3", "1.4"]`. + /// + /// The header value is split at its commas and every entry is trimmed, while empty entries are dropped. + /// The result is empty when the header is absent, which is also what tells a response the notes app produced apart from one the server or a proxy produced on its own behalf, because the app adds the header to every response it sends itself. + /// The notes features of ``Server`` use this both to enforce ``Notes/minimumAPIVersion`` through ``Notes/supports(apiVersions:)`` and as the gate deciding whether a status carries the notes app's meaning when mapping it onto a ``RainmakerError``. + /// + var notesAPIVersions: [String] { + (value(forHTTPHeaderField: "X-Notes-API-Versions") ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.isEmpty == false } + } +} diff --git a/Sources/Rainmaker/Extensions/NoteChanges+NoteChangeSet.swift b/Sources/Rainmaker/Extensions/NoteChanges+NoteChangeSet.swift new file mode 100644 index 0000000..0501b3a --- /dev/null +++ b/Sources/Rainmaker/Extensions/NoteChanges+NoteChangeSet.swift @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension NoteChanges: NoteChangeSet { + /// + /// Always `false`, because the notes ``NoteChanges/changed`` holds are decoded as ``Note``, which requires the content of each note, so the listings returning these changes never ask the server to leave it out. + /// + static let excludesContent = false +} diff --git a/Sources/Rainmaker/Extensions/NoteSummaryChanges+NoteChangeSet.swift b/Sources/Rainmaker/Extensions/NoteSummaryChanges+NoteChangeSet.swift new file mode 100644 index 0000000..6f56041 --- /dev/null +++ b/Sources/Rainmaker/Extensions/NoteSummaryChanges+NoteChangeSet.swift @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension NoteSummaryChanges: NoteChangeSet { + /// + /// Always `true`, because leaving out the content of every note is what the listings returning these changes, such as ``Server/noteSummaries(changedSince:)``, exist for, and ``NoteSummary`` has no property to hold it. + /// + static let excludesContent = true +} diff --git a/Sources/Rainmaker/Extensions/String+entityTag.swift b/Sources/Rainmaker/Extensions/String+entityTag.swift new file mode 100644 index 0000000..26a7e9c --- /dev/null +++ b/Sources/Rainmaker/Extensions/String+entityTag.swift @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension String { + /// + /// This entity tag wrapped in double quotes, as the `If-None-Match` and `If-Match` headers require it, unless it is quoted already. + /// + /// The notes features of ``Server`` hand out entity tags without quotes, for example as ``NoteChanges/entityTag``, while the server only answers a conditional request with `304 Not Modified` when the header repeats the quoted tag exactly. + /// Apply it to ``unquotedEntityTag`` rather than to a tag of unknown shape, so that a weak tag does not end up quoted around its `W/` prefix. + /// + var quotedEntityTag: String { + guard count >= 2, hasPrefix("\""), hasSuffix("\"") else { + return "\"\(self)\"" + } + + return self + } + + /// + /// This entity tag without a leading `W/` and without the double quotes around it, e.g. `"abc"` for `W/"abc"`. + /// + /// This is the form in which the notes features of ``Server`` hand out the `ETag` header of a response, for example as ``NoteChanges/entityTag``. + /// The weakness marker is dropped on purpose: a proxy compressing a response may weaken its tag, while the server compares a conditional request against the strong tag it computed, so only the bare value turned back into a header by ``quotedEntityTag`` can still be answered with `304 Not Modified`. + /// + var unquotedEntityTag: String { + var tag = trimmingCharacters(in: .whitespaces) + + if tag.hasPrefix("W/") { + tag.removeFirst(2) + } + + if tag.count >= 2, tag.hasPrefix("\""), tag.hasSuffix("\"") { + tag.removeFirst() + tag.removeLast() + } + + return tag + } +} diff --git a/Sources/Rainmaker/Extensions/URLComponents+encodedQueryItems.swift b/Sources/Rainmaker/Extensions/URLComponents+encodedQueryItems.swift new file mode 100644 index 0000000..95a357c --- /dev/null +++ b/Sources/Rainmaker/Extensions/URLComponents+encodedQueryItems.swift @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +extension URLComponents { + /// + /// The characters a name or a value of a query item may contain without being percent-encoded, which are those `urlQueryAllowed` permits except for `+`, `&`, `=` and `#`. + /// + /// `queryItems` leaves `+`, `&` and `=` as they are, because they are legal in a query, but the server reads `+` as a space and `&` and `=` as the delimiters of the items, so a value containing them reaches the server as something else, for example a file name `a+b.png` as `a b.png`. ``setEncodedQueryItems(_:)`` encodes them as well, which is what ``Server/makeAppRequest(for:method:queryItems:)`` and ``Server/makeOCSRequest(for:method:queryItems:)`` rely on. + /// + static let queryItemAllowedCharacters = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "+&=#")) + + /// + /// Set the query to the given items, percent-encoding every character of their names and values outside ``queryItemAllowedCharacters``. + /// + /// Unlike assigning `queryItems`, this also encodes `+`, `&`, `=` and `#`, so that every name and value reaches the server exactly as given. Items made of letters, digits and the other characters `urlQueryAllowed` permits are encoded the same way as through `queryItems`, so the query of such items does not change. + /// + /// - Parameters: + /// - items: The query items to set, in the order they should appear, with names and values which are not percent-encoded yet. + /// + mutating func setEncodedQueryItems(_ items: [URLQueryItem]) { + percentEncodedQueryItems = items.map { item in + URLQueryItem(name: Self.percentEncodedQueryComponent(item.name), value: item.value.map(Self.percentEncodedQueryComponent)) + } + } + + /// + /// Percent-encode a name or a value of a query item for ``setEncodedQueryItems(_:)``. + /// + /// Foundation only fails to encode a string which is not valid Unicode, which a Swift `String` cannot be, so the empty string it falls back to is never used in practice. It is chosen over the unencoded string because `percentEncodedQueryItems` traps on a component which is not correctly percent-encoded. + /// + private static func percentEncodedQueryComponent(_ component: String) -> String { + component.addingPercentEncoding(withAllowedCharacters: queryItemAllowedCharacters) ?? "" + } +} diff --git a/Sources/Rainmaker/Models/LoginFlow.swift b/Sources/Rainmaker/Models/LoginFlow.swift index 9f6017a..45132e5 100644 --- a/Sources/Rainmaker/Models/LoginFlow.swift +++ b/Sources/Rainmaker/Models/LoginFlow.swift @@ -6,19 +6,40 @@ import Foundation /// /// All the necessary information to begin and monitor a client login flow. /// -public struct LoginFlow: Model { +/// ``Server/login()`` begins a flow and returns this, and ``Server/poll(_:)`` takes it to check whether the user completed the flow, which yields a ``LoginResult``. +/// A client which keeps a flow across launches, or a test which fakes one, creates it through ``init(endpoint:entry:token:)``. +/// +public struct LoginFlow: Model, Hashable { /// - /// The endpoint to poll for the status. + /// The endpoint to poll for the status, which ``Server/poll(_:)`` sends ``token`` to. /// public let endpoint: URL /// /// The initial web page to present to the user for logging in. /// + /// ``Server/poll(_:)`` does not use it. + /// public let entry: URL /// - /// The token the specific login flow is identified by. + /// The token the specific login flow is identified by, which ``Server/poll(_:)`` sends to ``endpoint``. + /// + /// It is a secret: whoever knows it can fetch the ``LoginResult`` once the user completed the flow. /// public let token: String + + /// + /// Create a login flow from its parts, for example to poll a flow begun earlier through ``Server/poll(_:)``. + /// + /// - Parameters: + /// - endpoint: The endpoint to poll for the status. + /// - entry: The initial web page to present to the user for logging in. + /// - token: The token the specific login flow is identified by. + /// + public init(endpoint: URL, entry: URL, token: String) { + self.endpoint = endpoint + self.entry = entry + self.token = token + } } diff --git a/Sources/Rainmaker/Models/LoginResult.swift b/Sources/Rainmaker/Models/LoginResult.swift index 9b8f108..cbe2dc5 100644 --- a/Sources/Rainmaker/Models/LoginResult.swift +++ b/Sources/Rainmaker/Models/LoginResult.swift @@ -6,19 +6,43 @@ import Foundation /// /// The result of a successfully completed login flow. /// -public struct LoginResult: Model { +/// ``Server/poll(_:)`` returns this once the user granted access to the ``LoginFlow`` it was given. +/// Its ``name`` and ``password`` are what a ``Server`` authenticating as that account is created with, at the address ``server``. +/// +public struct LoginResult: Model, Hashable { + /// + /// The login name to authenticate with together with ``password``. /// - /// The user name to log in with. + /// This is the name the user logged in with in the browser, which can differ from the identifier of the account, for example when the server accepts an email address or a login attribute of an LDAP directory. + /// The identifier of the account, not this name, is what the server keys the WebDAV paths and the avatar of the account by, and ``Server/currentUser()`` called on a ``Server`` authenticating with this result returns it as ``User/id``. /// public let name: String /// - /// The (app) password provided by the server. + /// The app password the server created for this client, which is what a ``Server`` authenticates with instead of the user's own password. + /// + /// ``Server/deleteAppPassword()`` revokes it again. /// public let password: String /// - /// The final server address itself. + /// The address of the server the flow was completed on, as the server derives it from the browser's request and its own configuration. + /// + /// It can differ from the address ``Server/login()`` was called on, for example when the browser reached the server under another host name or the server is configured to overwrite its host or protocol. /// public let server: URL + + /// + /// Create a login result from its parts, for example to fake one in a test or a preview. + /// + /// - Parameters: + /// - name: The login name to authenticate with. + /// - password: The app password the server created. + /// - server: The address of the server the flow was completed on. + /// + public init(name: String, password: String, server: URL) { + self.name = name + self.password = password + self.server = server + } } diff --git a/Sources/Rainmaker/Models/Note.swift b/Sources/Rainmaker/Models/Note.swift index 13d28c5..3e1b36f 100644 --- a/Sources/Rainmaker/Models/Note.swift +++ b/Sources/Rainmaker/Models/Note.swift @@ -6,10 +6,14 @@ import Foundation /// /// A single note of the authenticated user on the server. /// -/// Notes are listed through ``Server/notes()`` and, incrementally, through ``Server/notes(changedSince:)``. Whether the notes app which provides them is available at all can be checked in advance via the ``Notes`` capability, e.g. `try await capabilities().contains(Notes.self)`. +/// Notes are listed through ``Server/notes()`` and, incrementally, through ``Server/notes(changedSince:)``, while a single one is retrieved by its ``id`` through ``Server/note(_:)``. +/// A client which does not need the text of the notes lists them as ``NoteSummary`` values through ``Server/noteSummaries(changedSince:)`` instead, which carry everything but the text and the error a failure to read it would cause. +/// Notes are created through ``Server/createNote(title:category:content:modification:isFavorite:)``, changed through ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` and deleted through ``Server/deleteNote(_:)``. Whether the notes app which provides them is available at all can be checked in advance via the ``Notes`` capability, e.g. `try await capabilities().contains(Notes.self)`. /// /// Every note is a file in the account's notes folder, which is why ``title`` doubles as its file name and ``category`` as the folder it sits in. -/// The attachments ``content`` may refer to are intentionally not modelled, as retrieving them is out of scope. +/// Where exactly that file is, is what ``path`` says, and whether it is shared with anyone is what ``isShared`` and ``shareTypes`` say. +/// +/// A note is usually decoded from a server's response, but ``init(id:entityTag:isReadOnly:title:category:content:hasError:errorType:isFavorite:modification:path:isShared:shareTypes:)`` creates one from its individual values, for example to stand in for a server's response in the tests of a downstream project. /// /// A note the server could not read is still listed rather than omitted, and the response still succeeds. Such a note carries ``hasError``, and its ``content`` is a message about the failure instead of the note's text. Anything which stores what it retrieves has to check that before writing, or it replaces a perfectly good local copy with an error message. /// @@ -23,6 +27,8 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust /// The entity tag of the note, which changes if and only if the note changes on the server. /// /// This corresponds to the server's `etag` field and is what a client compares against to learn whether its local copy is stale. + /// ``Server/note(_:ifChangedFrom:)`` takes it to have the server make that comparison and skip the transfer of a note which did not change. + /// ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` takes it to change the note only while the server still has that very copy. /// public let entityTag: String @@ -87,6 +93,67 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust /// public let modification: Date + /// + /// The path of the note's file relative to the account's files, with a leading slash, e.g. `"/Notes/Recipes/Pancakes.md"`. + /// + /// This corresponds to the server's `internalPath` field and is what makes the note's file reachable over WebDAV, for example through ``Server/info(_:)`` or ``Server/download(_:to:force:)``. + /// It starts with ``NotesSettings/notesPath`` and continues with ``category`` and the file name derived from ``title``, but asking for it rather than assembling it is the only reliable way, because the server sanitizes both and picks the file extension. + /// The attachments of a note are files as well: releases of the notes app from 6.1.0 on, see ``Notes/storesAttachmentsPerNote``, keep those uploaded through the notes API in a hidden `.attachments.` folder in the same folder as this file, where `` is ``id``. + /// + /// Every release of the notes app serving ``Notes/minimumAPIVersion`` sends it, so this is `nil` only for a note created without it through the memberwise initializer or decoded from a payload written by hand. + /// + public let path: String? + + /// + /// Whether the note's file is shared with anyone, which is the case exactly when ``shareTypes`` is not empty. + /// + /// This corresponds to the server's `isShared` field. It reports the shares the owner of the note's file created for it, which is the authenticated user for every note in their own notes folder. + /// + public let isShared: Bool + + /// + /// The kinds of share the note's file is part of, one entry per kind, which is empty when ``isShared`` is `false`. + /// + /// This corresponds to the server's `shareTypes` field. The server only looks for the kinds ``ShareType`` has named constants for, but any other number it might send in future is kept rather than dropped. + /// + public let shareTypes: [ShareType] + + /// + /// Create a note from its individual values, for example to stand in for a server's response in the tests of a downstream project. + /// + /// The defaults are what the server sends for an ordinary, uncategorized note it read without trouble and which is neither a favorite nor shared. + /// + /// - Parameters: + /// - id: The server-assigned identifier, see ``id``. + /// - entityTag: The entity tag, see ``entityTag``. + /// - isReadOnly: Whether the note cannot be edited, see ``isReadOnly``. Defaults to `false`. + /// - title: The title, see ``title``. + /// - category: The category, see ``category``. Defaults to an empty string, meaning uncategorized. + /// - content: The text, see ``content``. + /// - hasError: Whether the server could not read the note, see ``hasError``. Defaults to `false`. + /// - errorType: The kind of error, see ``errorType``. Defaults to an empty string. + /// - isFavorite: Whether the note is a favorite, see ``isFavorite``. Defaults to `false`. + /// - modification: The moment of the last modification, see ``modification``. + /// - path: The path of the note's file, see ``path``. Defaults to `nil`. + /// - isShared: Whether the note's file is shared, see ``isShared``. Defaults to `false`. + /// - shareTypes: The kinds of share the note's file is part of, see ``shareTypes``. Defaults to none. + /// + public init(id: Int, entityTag: String, isReadOnly: Bool = false, title: String, category: String = "", content: String, hasError: Bool = false, errorType: String = "", isFavorite: Bool = false, modification: Date, path: String? = nil, isShared: Bool = false, shareTypes: [ShareType] = []) { + self.id = id + self.entityTag = entityTag + self.isReadOnly = isReadOnly + self.title = title + self.category = category + self.content = content + self.hasError = hasError + self.errorType = errorType + self.isFavorite = isFavorite + self.modification = modification + self.path = path + self.isShared = isShared + self.shareTypes = shareTypes + } + /// /// The keys a note is decoded from, which are the names the server sends. Encoding uses the separate encoding keys below so that the server's naming does not leak into the encoded form. /// @@ -101,6 +168,9 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust case errorType case isFavorite = "favorite" case modification = "modified" + case path = "internalPath" + case isShared + case shareTypes } // MARK: - Decodable @@ -108,7 +178,9 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust /// /// Decode a note from the server's payload. /// - /// Every field is required, ``hasError`` and ``errorType`` included: the server has sent those since it first served version 1 of the API. ``Notes/minimumAPIVersion`` is enforced before a payload reaches this type, and that version sends all of them, so a missing field means a response this type cannot describe rather than an older server. The notes this library requests are also never reduced by an `exclude` parameter, and the reduced form a `pruneBefore` request produces is recognized before decoding is attempted. + /// Every field but ``path``, ``isShared`` and ``shareTypes`` is required, ``hasError`` and ``errorType`` included: the server has sent those since it first served version 1 of the API. ``Notes/minimumAPIVersion`` is enforced before a payload reaches this type, and that version sends all of them, so a missing field means a response this type cannot describe rather than an older server. The notes this library decodes as a note are also never reduced by an `exclude` parameter, because the listings which leave out the content decode ``NoteSummary`` instead, and the reduced form a `pruneBefore` request produces is recognized before decoding is attempted. + /// + /// The server sends ``path``, ``isShared`` and ``shareTypes`` as well, since long before ``Notes/minimumAPIVersion``. They are decoded only if present all the same, falling back to `nil`, `false` and no share types, so that a payload written by hand, for example in the tests of a downstream project, does not have to carry them. /// public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -126,6 +198,10 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust // Decoding into a `TimeInterval` rather than an integer avoids the trap an out of range value would cause on the platforms where `Int` is only 32 bits wide. let secondsSince1970 = try container.decode(TimeInterval.self, forKey: .modification) modification = Date(timeIntervalSince1970: secondsSince1970) + + path = try container.decodeIfPresent(String.self, forKey: .path) + isShared = try container.decodeIfPresent(Bool.self, forKey: .isShared) ?? false + shareTypes = try container.decodeIfPresent([ShareType].self, forKey: .shareTypes) ?? [] } // MARK: - Encodable @@ -133,7 +209,7 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust /// /// The keys a note is encoded under, which are the property names rather than the names the server sends. /// - /// Encoding deliberately does not reuse ``CodingKeys``: those exist to read the server's payload and carry its naming, which would leak back out into anything this library encodes. Keeping the two apart is what makes the encoded form match the model a Swift caller sees, including where a property was renamed for clarity such as ``modification`` over the server's `modified`. + /// Encoding deliberately does not reuse ``CodingKeys``: those exist to read the server's payload and carry its naming, which would leak back out into anything this library encodes. Keeping the two apart is what makes the encoded form match the model a Swift caller sees, including where a property was renamed for clarity such as ``modification`` over the server's `modified` or ``path`` over its `internalPath`. /// private enum EncodingKeys: String, CodingKey { case id @@ -146,6 +222,9 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust case errorType case isFavorite case modification + case path + case isShared + case shareTypes } /// @@ -164,6 +243,9 @@ public struct Note: Model, Hashable, Identifiable, CustomStringConvertible, Cust try container.encode(errorType, forKey: .errorType) try container.encode(isFavorite, forKey: .isFavorite) try container.encode(modification, forKey: .modification) + try container.encode(path, forKey: .path) + try container.encode(isShared, forKey: .isShared) + try container.encode(shareTypes, forKey: .shareTypes) } // MARK: - CustomStringConvertible diff --git a/Sources/Rainmaker/Models/NoteAttachment.swift b/Sources/Rainmaker/Models/NoteAttachment.swift new file mode 100644 index 0000000..81336b8 --- /dev/null +++ b/Sources/Rainmaker/Models/NoteAttachment.swift @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// A file a note refers to, such as an image embedded into it, as ``Server/attachment(at:ofNote:)`` retrieves it into memory. +/// +/// Its counterpart for a file written to a local location instead is ``NoteAttachmentFile``, which ``Server/downloadAttachment(at:ofNote:to:force:)`` returns. +/// +/// There is deliberately no file name. The server names one in its `Content-Disposition` header, but as raw UTF-8 bytes which Foundation reads as Latin-1, so any name beyond plain ASCII would arrive garbled. The last component of the path the attachment was retrieved by is the reliable name, because that path names the file. +/// +public struct NoteAttachment: Model, Hashable { + /// + /// The bytes of the file exactly as the server sent them. + /// + public let data: Data + + /// + /// The MIME type of ``data`` as the server states it, e.g. `"image/png"`. + /// + /// The server derives it from the extension of the file name rather than from the bytes, and it reports types a browser would render as a document, which are SVG and HTML, as `"text/plain"`, so that opening an attachment cannot run anything. A type it does not know is `"application/octet-stream"`, which is also what a response without a type is taken to be. + /// + public let contentType: String + + /// + /// Create an attachment from its bytes and their type, for example to fake the result of ``Server/attachment(at:ofNote:)``. + /// + /// - Parameters: + /// - data: The bytes of the file. + /// - contentType: The MIME type of the bytes. + /// + public init(data: Data, contentType: String) { + self.data = data + self.contentType = contentType + } +} diff --git a/Sources/Rainmaker/Models/NoteAttachmentFile.swift b/Sources/Rainmaker/Models/NoteAttachmentFile.swift new file mode 100644 index 0000000..cdf5a9b --- /dev/null +++ b/Sources/Rainmaker/Models/NoteAttachmentFile.swift @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// A file a note refers to, such as an image embedded into it, as ``Server/downloadAttachment(at:ofNote:to:force:)`` wrote it to a local location. +/// +/// This is the counterpart of ``NoteAttachment`` for a file which is not held in memory but streamed to disk, which suits large files and a process with little memory to spare, such as an extension. As there, the last component of the path the attachment was retrieved by is its reliable name rather than anything the server says about it. +/// +public struct NoteAttachmentFile: Model, Hashable { + /// + /// The local location the file was written to, which is the destination ``Server/downloadAttachment(at:ofNote:to:force:)`` was given. + /// + public let location: URL + + /// + /// The MIME type of the file as the server states it, e.g. `"image/png"`, with the same caveats as ``NoteAttachment/contentType``. + /// + public let contentType: String + + /// + /// Create a downloaded attachment from its location and its type, for example to fake the result of ``Server/downloadAttachment(at:ofNote:to:force:)``. + /// + /// - Parameters: + /// - location: The local location of the file. + /// - contentType: The MIME type of the file. + /// + public init(location: URL, contentType: String) { + self.location = location + self.contentType = contentType + } +} diff --git a/Sources/Rainmaker/Models/NoteChanges.swift b/Sources/Rainmaker/Models/NoteChanges.swift index 9e0afb9..cd7b9e4 100644 --- a/Sources/Rainmaker/Models/NoteChanges.swift +++ b/Sources/Rainmaker/Models/NoteChanges.swift @@ -4,12 +4,15 @@ import Foundation /// -/// The notes which changed since a given moment, together with the identifiers of those which did not. +/// The notes which changed since a given moment, together with the identifiers of those which did not, and what the server said about the response itself. /// -/// This is what ``Server/notes(changedSince:)`` returns. The server answers such a request with the full content of every note it recorded a change for since the given moment and reduces every other note to its identifier alone, which is why the two arrive separately here. Both together are the complete set of notes the account has, so a note whose identifier appears in neither was deleted on the server: +/// This is what ``Server/notes(changedSince:)`` and ``Server/notes(changedSince:ifChangedFrom:)`` return, and what each chunk of ``Server/notes(changedSince:chunkSize:continuingAfter:)``, ``Server/notes(changedSince:chunkSize:ifChangedFrom:)`` and ``Server/noteChunks(changedSince:chunkSize:)`` is. The server answers such a request with the full content of every note it recorded a change for since the given moment and reduces every other note to its identifier alone, which is why the two arrive separately here. Both together are the complete set of notes the account has, so a note whose identifier appears in neither was deleted on the server. +/// The listings which ask the server to leave out the text of every note, such as ``Server/noteSummaries(changedSince:)``, return the counterpart ``NoteSummaryChanges`` instead, which holds ``NoteSummary`` values in place of notes. +/// +/// The moment to pass on the next call is ``lastModified``, which is the server's own clock rather than the device's. The first synchronization passes `Date.distantPast` instead, which prunes nothing and therefore also yields a ``lastModified`` to continue from: /// /// ```swift -/// let changes = try await server.notes(changedSince: lastSynchronization) +/// let changes = try await server.notes(changedSince: store.lastModified ?? .distantPast) /// /// for note in changes.changed { /// // A note the server could not read is listed like any other, carrying a message about the failure in place of its text. Storing it would replace a perfectly good local copy with that message. @@ -21,13 +24,26 @@ import Foundation /// store.upsert(note) /// } /// +/// // Only a complete response lists every note the account has, so deletions are derived from nothing else. +/// guard changes.isComplete else { +/// return +/// } +/// /// // Every identifier the server returned, the unreadable ones included, so a note it could not read this time is not mistaken for one which was deleted. /// store.deleteAll(exceptFor: changes.changed.map(\.id) + changes.unchanged) +/// +/// // Remembered only once everything was applied, so an interrupted synchronization repeats rather than skips what it missed. +/// store.lastModified = changes.lastModified +/// store.entityTag = changes.entityTag /// ``` /// -/// Which moment to pass on the next call is left to the caller, which usually remembers when its previous synchronization started. There is deliberately no cursor in here, because the server communicates it as a response header this library does not surface. It has to come from a clock comparable to the server's rather than from a note's ``Note/modification`` date, because the server prunes by when it noticed a change: a note may be from 2020, but when the server only found it today it is not pruned. See ``Server/notes(changedSince:)``. +/// Passing the remembered ``entityTag`` to ``Server/notes(changedSince:ifChangedFrom:)`` along with the remembered ``lastModified`` spares the transfer altogether while nothing changed, because the server then answers with an empty `304 Not Modified`, which that call returns as `nil` and which leaves both remembered values as they are. +/// +/// The moment must never come from a note's ``Note/modification`` date, because the server prunes by when it noticed a change rather than by the date of the note: a note may be from 2020, but when the server only found it today it is not pruned. See ``Server/notes(changedSince:)``. /// -public struct NoteChanges: Model { +/// The server can also split such a response into chunks when asked to by ``Server/notes(changedSince:chunkSize:continuingAfter:)`` or ``Server/noteChunks(changedSince:chunkSize:)``, each of which but the last carries a ``chunkCursor`` to continue from and the ``pendingCount`` of notes still to come. Only the last chunk lists the identifiers of the notes not sent in full, those sent by the earlier chunks included, which is why ``isComplete`` has to be checked before deriving deletions, and why ``lastModified`` is only worth remembering once the last chunk was applied. +/// +public struct NoteChanges: Model, Hashable { /// /// The notes the server recorded a change for at or after the requested moment, in the order returned by the server. /// @@ -40,6 +56,103 @@ public struct NoteChanges: Model { /// The identifiers of the notes the server recorded no change for and therefore reduced to just those identifiers. /// /// These carry no content on purpose: a client already has it and only needs to know the note still exists. + /// When the response is one chunk of several, only the last one, which ``isComplete``, carries these, and then also for the notes the earlier chunks sent in full. /// public let unchanged: [Int] + + /// + /// The moment the server started answering, read from the `Last-Modified` header of the response, which is the value to pass as `changedSince` on the next call to ``Server/notes(changedSince:)``. + /// + /// This is measured on the server's clock, which is what the server prunes against, so a device whose clock is off can neither miss nor repeat changes by using it. + /// Every chunk of one chunked retrieval carries the same value, because the server keeps the moment the first chunk was requested in the ``chunkCursor``. + /// The server only sends whole seconds and keeps every note which changed in the very second it names, so reusing it never skips a change made while the response was being built. + /// This is `nil` when the header is absent or is not an HTTP date, which the notes app never causes on its own. + /// + public let lastModified: Date? + + /// + /// The entity tag of the response, read from its `ETag` header without the quotes and without a `W/` prefix. + /// + /// Pass it to ``Server/notes(changedSince:ifChangedFrom:)`` together with ``lastModified`` to learn without a transfer whether anything changed since. + /// The server computes it from the body of the response, so it matches a later response exactly when that would have the same body, regardless of the moment it was requested for. + /// It describes the whole response, unlike ``Note/entityTag``, which describes one note. + /// This is `nil` when the header is absent. + /// + public let entityTag: String? + + /// + /// The opaque position to continue a chunked retrieval from, read from the `X-Notes-Chunk-Cursor` header of the response, which ``Server/notes(changedSince:chunkSize:continuingAfter:)`` takes to retrieve the next chunk. + /// + /// This is `nil` for the last chunk and for a response which was not split into chunks at all, which is what ``isComplete`` reports. + /// Its format is the notes app's business, so it is meant to be handed back as it is rather than to be read. + /// + public let chunkCursor: String? + + /// + /// How many notes the server still has to send in full after this chunk, read from the `X-Notes-Chunk-Pending` header of the response. + /// + /// This is `nil` whenever ``chunkCursor`` is, because the server only sends either of them while notes are pending. + /// + public let pendingCount: Int? + + /// + /// Whether this is the last chunk of a retrieval or a response which was not split into chunks at all, which is when ``chunkCursor`` is `nil`. + /// + /// Only such a response lists the identifiers of every note the account has, so deletions must only be derived from it and ``lastModified`` should only be remembered after it was applied. + /// + public var isComplete: Bool { + chunkCursor == nil + } + + /// + /// Create changes from their individual values, for example to stand in for a server's response in the tests of a downstream project. + /// + /// - Parameters: + /// - changed: The notes sent in full, see ``changed``. + /// - unchanged: The identifiers of the notes sent as identifiers alone, see ``unchanged``. + /// - lastModified: The moment to continue from, see ``lastModified``. Defaults to `nil`. + /// - entityTag: The entity tag of the response without quotes, see ``entityTag``. Defaults to `nil`. + /// - chunkCursor: The position to continue a chunked retrieval from, see ``chunkCursor``. Defaults to `nil`, which makes the changes complete. + /// - pendingCount: How many notes are still to come, see ``pendingCount``. Defaults to `nil`. + /// + public init(changed: [Note], unchanged: [Int], lastModified: Date? = nil, entityTag: String? = nil, chunkCursor: String? = nil, pendingCount: Int? = nil) { + self.changed = changed + self.unchanged = unchanged + self.lastModified = lastModified + self.entityTag = entityTag + self.chunkCursor = chunkCursor + self.pendingCount = pendingCount + } + + // MARK: - Encodable + + /// + /// The keys changes are encoded under, which are the property names. + /// + /// ``isComplete`` is not among them, because it is derived from ``chunkCursor`` alone. + /// + private enum EncodingKeys: String, CodingKey { + case changed + case unchanged + case lastModified + case entityTag + case chunkCursor + case pendingCount + } + + /// + /// Encode changes under their property names. + /// + /// Absent values are encoded as `null` rather than left out, so that the encoded form always has the same keys, as with ``NotesSettings``. + /// + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + + try container.encode(changed, forKey: .changed) + try container.encode(unchanged, forKey: .unchanged) + try container.encode(lastModified, forKey: .lastModified) + try container.encode(entityTag, forKey: .entityTag) + try container.encode(chunkCursor, forKey: .chunkCursor) + try container.encode(pendingCount, forKey: .pendingCount) + } } diff --git a/Sources/Rainmaker/Models/NoteMode.swift b/Sources/Rainmaker/Models/NoteMode.swift new file mode 100644 index 0000000..3d0eb9b --- /dev/null +++ b/Sources/Rainmaker/Models/NoteMode.swift @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The way the notes app's web interface presents a note when it is opened. +/// +/// This is the ``NotesSettings/noteMode`` of an account as retrieved through ``Server/notesSettings()`` and changed through ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)``. It is a preference of the web interface only: the notes API serves the same Markdown in ``Note/content`` whatever it says, so a client may honor it to feel familiar but does not have to. +/// +/// The raw values are the strings the server sends. A value this library does not know about does not decode as a case but leaves ``NotesSettings/noteMode`` `nil`, which is why this enum needs no catch-all case. +/// +public enum NoteMode: String, Model, Hashable, CaseIterable { + /// + /// Notes open in the rich text editor of the Nextcloud Text app, equalling the raw representation as `"rich"`. + /// + /// The notes app offers this mode only when the Text app is enabled, and then it is also the default. + /// + case rich + + /// + /// Notes open in the notes app's own plain Markdown editor, equalling the raw representation as `"edit"`. + /// + /// This is the default when the Text app is not enabled. + /// + case edit + + /// + /// Notes open as rendered Markdown which is not editable until the user switches to editing, equalling the raw representation as `"preview"`. + /// + case preview +} diff --git a/Sources/Rainmaker/Models/NoteSummary.swift b/Sources/Rainmaker/Models/NoteSummary.swift new file mode 100644 index 0000000..cff9f1c --- /dev/null +++ b/Sources/Rainmaker/Models/NoteSummary.swift @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// A single note of the authenticated user on the server, described by everything but its text. +/// +/// This is what the listings which leave out the content of the notes report, ``Server/noteSummaries(changedSince:)`` and its conditional and chunked counterparts, as the ``NoteSummaryChanges/changed`` notes of a ``NoteSummaryChanges``. +/// They ask the server not to send the text of any note, so a client which does not need it, such as one which only shows titles or which deliberately keeps no copy of a note's text, never downloads it. Retrieve a single note in full through ``Server/note(_:)`` when its text is needed after all. +/// Every property means exactly what the property of the same name of ``Note`` means, and the server sends the same values for both, including the ``entityTag``, with the one exception of ``isReadOnly`` explained below. +/// +/// A summary deliberately has no counterpart to ``Note/hasError`` and ``Note/errorType``. The server only notices that it cannot read a note while reading its text, which a summary listing asks it to skip, so it then always reports that there was no error. A property which could only ever say so would claim that every note was readable. For the same reason ``isReadOnly`` is not forced for a note the server could not read, as it is for a ``Note``, but reflects the permissions on the note's file alone. +/// +/// A summary is usually decoded from a server's response, but ``init(id:entityTag:isReadOnly:title:category:isFavorite:modification:path:isShared:shareTypes:)`` creates one from its individual values, for example to stand in for a server's response in the tests of a downstream project. +/// +public struct NoteSummary: Model, Hashable, Identifiable, CustomStringConvertible, CustomDebugStringConvertible, Decodable { + /// + /// The server-assigned identifier of the note, unique per account, which is the same as ``Note/id``. + /// + public let id: Int + + /// + /// The entity tag of the note, which changes if and only if the note changes on the server, its text included. + /// + /// This corresponds to the server's `etag` field and is the very ``Note/entityTag`` a ``Note`` of the same note carries, because the server derives it from the note's values and a digest of its text it keeps, whether or not the text is sent. + /// It can therefore be passed to ``Server/note(_:ifChangedFrom:)`` to retrieve the note in full only when it is not the one at hand, and to ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` to change it only while the server still has it as summarized. + /// + public let entityTag: String + + /// + /// Whether the note cannot be edited, for example because it was shared by another user without granting write access. + /// + /// This corresponds to the server's `readonly` field. Unlike ``Note/isReadOnly`` it is never forced by a failure to read the note, because a summary listing does not read the note's text. + /// + public let isReadOnly: Bool + + /// + /// The title of the note, which the server also uses as the file name of the note's file, see ``Note/title``. + /// + public let title: String + + /// + /// The category the note is filed under, which the server maps to a folder inside the notes folder, see ``Note/category``. + /// + /// This is an empty string rather than `nil` when the note is uncategorized, matching what the server sends. + /// + public let category: String + + /// + /// Whether the note is marked as a favorite, which clients customarily surface at the top of a list. + /// + public let isFavorite: Bool + + /// + /// The moment the note was last modified, see ``Note/modification``. + /// + /// This corresponds to the server's `modified` field, a number of whole seconds since the Unix epoch, and must never be passed to ``Server/noteSummaries(changedSince:)`` for the reasons ``Note/modification`` gives. + /// + public let modification: Date + + /// + /// The path of the note's file relative to the account's files, with a leading slash, see ``Note/path``. + /// + /// Every release of the notes app serving ``Notes/minimumAPIVersion`` sends it, so this is `nil` only for a summary created without it through the memberwise initializer or decoded from a payload written by hand. + /// + public let path: String? + + /// + /// Whether the note's file is shared with anyone, which is the case exactly when ``shareTypes`` is not empty, see ``Note/isShared``. + /// + public let isShared: Bool + + /// + /// The kinds of share the note's file is part of, one entry per kind, which is empty when ``isShared`` is `false`, see ``Note/shareTypes``. + /// + public let shareTypes: [ShareType] + + /// + /// Create a summary from its individual values, for example to stand in for a server's response in the tests of a downstream project. + /// + /// The defaults are what the server sends for an ordinary, uncategorized note which is neither a favorite nor shared. + /// + /// - Parameters: + /// - id: The server-assigned identifier, see ``id``. + /// - entityTag: The entity tag, see ``entityTag``. + /// - isReadOnly: Whether the note cannot be edited, see ``isReadOnly``. Defaults to `false`. + /// - title: The title, see ``title``. + /// - category: The category, see ``category``. Defaults to an empty string, meaning uncategorized. + /// - isFavorite: Whether the note is a favorite, see ``isFavorite``. Defaults to `false`. + /// - modification: The moment of the last modification, see ``modification``. + /// - path: The path of the note's file, see ``path``. Defaults to `nil`. + /// - isShared: Whether the note's file is shared, see ``isShared``. Defaults to `false`. + /// - shareTypes: The kinds of share the note's file is part of, see ``shareTypes``. Defaults to none. + /// + public init(id: Int, entityTag: String, isReadOnly: Bool = false, title: String, category: String = "", isFavorite: Bool = false, modification: Date, path: String? = nil, isShared: Bool = false, shareTypes: [ShareType] = []) { + self.id = id + self.entityTag = entityTag + self.isReadOnly = isReadOnly + self.title = title + self.category = category + self.isFavorite = isFavorite + self.modification = modification + self.path = path + self.isShared = isShared + self.shareTypes = shareTypes + } + + /// + /// The keys a summary is decoded from, which are the names the server sends and the same as those ``Note`` is decoded from, but for the content and the error the server sends for a note without its text. + /// + private enum CodingKeys: String, CodingKey { + case id + case entityTag = "etag" + case isReadOnly = "readonly" + case title + case category + case isFavorite = "favorite" + case modification = "modified" + case path = "internalPath" + case isShared + case shareTypes + } + + // MARK: - Decodable + + /// + /// Decode a summary from the server's payload. + /// + /// The same fields as for ``Note/init(from:)`` are required, but for `content`, `error` and `errorType`, which are not read even when present: the server sends no content for a note a summary listing asks about, and only ever reports that there was no error then. ``path``, ``isShared`` and ``shareTypes`` are decoded only if present, falling back to `nil`, `false` and no share types, as with ``Note``. + /// + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(Int.self, forKey: .id) + entityTag = try container.decode(String.self, forKey: .entityTag) + isReadOnly = try container.decode(Bool.self, forKey: .isReadOnly) + title = try container.decode(String.self, forKey: .title) + category = try container.decode(String.self, forKey: .category) + isFavorite = try container.decode(Bool.self, forKey: .isFavorite) + + // Decoding into a `TimeInterval` rather than an integer avoids the trap an out of range value would cause on the platforms where `Int` is only 32 bits wide. + let secondsSince1970 = try container.decode(TimeInterval.self, forKey: .modification) + modification = Date(timeIntervalSince1970: secondsSince1970) + + path = try container.decodeIfPresent(String.self, forKey: .path) + isShared = try container.decodeIfPresent(Bool.self, forKey: .isShared) ?? false + shareTypes = try container.decodeIfPresent([ShareType].self, forKey: .shareTypes) ?? [] + } + + // MARK: - Encodable + + /// + /// The keys a summary is encoded under, which are the property names rather than the names the server sends, as with ``Note``. + /// + private enum EncodingKeys: String, CodingKey { + case id + case entityTag + case isReadOnly + case title + case category + case isFavorite + case modification + case path + case isShared + case shareTypes + } + + /// + /// Encode a summary under its property names, so that the encoded form mirrors this type rather than the server's payload. + /// + /// Like ``Note`` it encodes an absent ``path`` as `null`, so that the encoded form always has the same keys. + /// + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(entityTag, forKey: .entityTag) + try container.encode(isReadOnly, forKey: .isReadOnly) + try container.encode(title, forKey: .title) + try container.encode(category, forKey: .category) + try container.encode(isFavorite, forKey: .isFavorite) + try container.encode(modification, forKey: .modification) + try container.encode(path, forKey: .path) + try container.encode(isShared, forKey: .isShared) + try container.encode(shareTypes, forKey: .shareTypes) + } + + // MARK: - CustomStringConvertible + + /// + /// Implementation for `CustomStringConvertible` conformance to have a concise and human-readable textual representation of a summary, which is the note's title as for ``Note``. + /// + public var description: String { + title + } + + // MARK: - CustomDebugStringConvertible + + /// + /// Implementation for `CustomDebugStringConvertible` conformance to have a concise and human-readable textual representation of a summary, in the same form as for ``Note``. + /// + public var debugDescription: String { + "#\(id) (\(category.isEmpty ? "uncategorized" : category)): \(title)" + } +} diff --git a/Sources/Rainmaker/Models/NoteSummaryChanges.swift b/Sources/Rainmaker/Models/NoteSummaryChanges.swift new file mode 100644 index 0000000..c1b8638 --- /dev/null +++ b/Sources/Rainmaker/Models/NoteSummaryChanges.swift @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The summaries of the notes which changed since a given moment, together with the identifiers of those which did not, and what the server said about the response itself. +/// +/// This is to ``NoteChanges`` what ``NoteSummary`` is to ``Note``: the result of the listings which ask the server to leave out the text of every note, ``Server/noteSummaries(changedSince:)`` and ``Server/noteSummaries(changedSince:ifChangedFrom:)``, and what each chunk of ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)``, ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)`` and ``Server/noteSummaryChunks(changedSince:chunkSize:)`` is. +/// Everything ``NoteChanges`` says about how the two halves are applied, about which moment to continue from and about chunks holds for this type as well, because the server answers both kinds of listing alike but for the text it leaves out. +/// +/// ```swift +/// let changes = try await server.noteSummaries(changedSince: store.lastModified ?? .distantPast) +/// +/// // A summary carries no error, because the server only notices one while reading a note's text, so every summary is stored as it is. +/// store.upsert(changes.changed) +/// +/// // Only a complete response lists every note the account has, so deletions are derived from nothing else. +/// guard changes.isComplete else { +/// return +/// } +/// +/// store.deleteAll(exceptFor: changes.changed.map(\.id) + changes.unchanged) +/// +/// // Remembered only once everything was applied, so an interrupted synchronization repeats rather than skips what it missed. +/// store.lastModified = changes.lastModified +/// store.entityTag = changes.entityTag +/// ``` +/// +/// A note whose text alone changed is among the ``changed`` summaries as well, with a new ``NoteSummary/entityTag``, because the server's record of when it last saw a note change covers the text even though the text is not sent. +/// +/// The ``entityTag`` describes a response without any text, so it only matches a later response of a summary listing. Pass it to ``Server/noteSummaries(changedSince:ifChangedFrom:)`` rather than to ``Server/notes(changedSince:ifChangedFrom:)``, where it would only match while neither response sends a note in full. +/// +public struct NoteSummaryChanges: Model, Hashable { + /// + /// The summaries of the notes the server recorded a change for at or after the requested moment, in the order returned by the server. + /// + /// Empty whenever nothing changed, which is the common case for a client polling frequently. + /// Unlike ``NoteChanges/changed`` this never reports a note the server could not read, because the server does not try to read any note's text for such a listing, see ``NoteSummary``. + /// + public let changed: [NoteSummary] + + /// + /// The identifiers of the notes the server recorded no change for and therefore reduced to just those identifiers. + /// + /// When the response is one chunk of several, only the last one, which ``isComplete``, carries these, and then also for the notes the earlier chunks sent in full, as with ``NoteChanges/unchanged``. + /// + public let unchanged: [Int] + + /// + /// The moment the server started answering, read from the `Last-Modified` header of the response, which is the value to pass as `changedSince` on the next call to ``Server/noteSummaries(changedSince:)``. + /// + /// It means exactly what ``NoteChanges/lastModified`` means, and it is `nil` when the header is absent or is not an HTTP date. + /// + public let lastModified: Date? + + /// + /// The entity tag of the response, read from its `ETag` header without the quotes and without a `W/` prefix. + /// + /// Pass it to ``Server/noteSummaries(changedSince:ifChangedFrom:)`` together with ``lastModified`` to learn without a transfer whether anything changed since. + /// The server computes it from the body of the response, which for a summary listing holds no text, so it differs from the ``NoteChanges/entityTag`` of a listing with text whenever a note is sent in full. + /// It describes the whole response, unlike ``NoteSummary/entityTag``, which describes one note. + /// This is `nil` when the header is absent. + /// + public let entityTag: String? + + /// + /// The opaque position to continue a chunked retrieval from, read from the `X-Notes-Chunk-Cursor` header of the response, which ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)`` takes to retrieve the next chunk. + /// + /// This is `nil` for the last chunk and for a response which was not split into chunks at all, which is what ``isComplete`` reports. + /// Its format is the notes app's business, so it is meant to be handed back as it is rather than to be read. + /// + public let chunkCursor: String? + + /// + /// How many notes the server still has to send in full after this chunk, read from the `X-Notes-Chunk-Pending` header of the response. + /// + /// This is `nil` whenever ``chunkCursor`` is, because the server only sends either of them while notes are pending. + /// + public let pendingCount: Int? + + /// + /// Whether this is the last chunk of a retrieval or a response which was not split into chunks at all, which is when ``chunkCursor`` is `nil`. + /// + /// Only such a response lists the identifiers of every note the account has, so deletions must only be derived from it and ``lastModified`` should only be remembered after it was applied. + /// + public var isComplete: Bool { + chunkCursor == nil + } + + /// + /// Create changes from their individual values, for example to stand in for a server's response in the tests of a downstream project. + /// + /// - Parameters: + /// - changed: The summaries of the notes sent in full, see ``changed``. + /// - unchanged: The identifiers of the notes sent as identifiers alone, see ``unchanged``. + /// - lastModified: The moment to continue from, see ``lastModified``. Defaults to `nil`. + /// - entityTag: The entity tag of the response without quotes, see ``entityTag``. Defaults to `nil`. + /// - chunkCursor: The position to continue a chunked retrieval from, see ``chunkCursor``. Defaults to `nil`, which makes the changes complete. + /// - pendingCount: How many notes are still to come, see ``pendingCount``. Defaults to `nil`. + /// + public init(changed: [NoteSummary], unchanged: [Int], lastModified: Date? = nil, entityTag: String? = nil, chunkCursor: String? = nil, pendingCount: Int? = nil) { + self.changed = changed + self.unchanged = unchanged + self.lastModified = lastModified + self.entityTag = entityTag + self.chunkCursor = chunkCursor + self.pendingCount = pendingCount + } + + // MARK: - Encodable + + /// + /// The keys changes are encoded under, which are the property names, the same as those of ``NoteChanges``. + /// + /// ``isComplete`` is not among them, because it is derived from ``chunkCursor`` alone. + /// + private enum EncodingKeys: String, CodingKey { + case changed + case unchanged + case lastModified + case entityTag + case chunkCursor + case pendingCount + } + + /// + /// Encode changes under their property names. + /// + /// Absent values are encoded as `null` rather than left out, so that the encoded form always has the same keys, as with ``NoteChanges``. + /// + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + + try container.encode(changed, forKey: .changed) + try container.encode(unchanged, forKey: .unchanged) + try container.encode(lastModified, forKey: .lastModified) + try container.encode(entityTag, forKey: .entityTag) + try container.encode(chunkCursor, forKey: .chunkCursor) + try container.encode(pendingCount, forKey: .pendingCount) + } +} diff --git a/Sources/Rainmaker/Models/Notes.swift b/Sources/Rainmaker/Models/Notes.swift index 43da508..5ce078d 100644 --- a/Sources/Rainmaker/Models/Notes.swift +++ b/Sources/Rainmaker/Models/Notes.swift @@ -27,12 +27,16 @@ public struct Notes: Capability { /// /// The minor component of ``minimumAPIVersion``. /// - static let minimumMinorAPIVersion = 3 + static let minimumMinorAPIVersion = 4 /// - /// The oldest version of the notes API this library works with, which notes app 4.5 introduced in 2022. + /// The oldest version of the notes API this library works with, which is notes API version 1.4 as introduced by notes app 4.12.3 in 2025. /// - /// Everything older is unsupported: ``Note/entityTag`` and ``Note/isReadOnly`` arrived with API version 1.2 and are relied upon rather than treated as optional, and custom file suffixes arrived with 1.3. A server whose notes app is older makes ``Server/notes()`` and ``Server/notes(changedSince:)`` throw ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``. + /// Version 1.4 is the one which exposes the attachments of a note, below a path every release of the notes app advertising it serves, so requiring it is what lets the notes features rely on that path rather than probe for it. + /// Everything older is unsupported: ``Note/entityTag`` and ``Note/isReadOnly`` arrived with API version 1.2 and are relied upon rather than treated as optional, custom file suffixes arrived with 1.3, and the attachment endpoints with 1.4. + /// A server whose notes app is older makes ``Server/notes()``, ``Server/notes(changedSince:)``, ``Server/notes(changedSince:ifChangedFrom:)``, ``Server/notes(changedSince:chunkSize:continuingAfter:)``, ``Server/notes(changedSince:chunkSize:ifChangedFrom:)``, ``Server/noteChunks(changedSince:chunkSize:)``, ``Server/noteSummaries(changedSince:)``, ``Server/noteSummaries(changedSince:ifChangedFrom:)``, ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)``, ``Server/noteSummaries(changedSince:chunkSize:ifChangedFrom:)``, ``Server/noteSummaryChunks(changedSince:chunkSize:)``, ``Server/note(_:)``, ``Server/note(_:ifChangedFrom:)``, ``Server/createNote(title:category:content:modification:isFavorite:)``, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``, ``Server/deleteNote(_:)``, ``Server/attachment(at:ofNote:)``, ``Server/downloadAttachment(at:ofNote:to:force:)``, ``Server/notesSettings()`` and ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` throw ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``. + /// An outdated release still performs a change before its answer reveals the version, so when ``Server/createNote(title:category:content:modification:isFavorite:)``, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``, ``Server/deleteNote(_:)`` or ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` throw this error, the note has already been created, changed or deleted, or the settings already changed. A caller which must not change anything on such a server checks ``isSupported`` first. + /// Adding and deleting attachments through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)``, ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)`` and ``Server/deleteAttachment(at:ofNote:)`` throws ``RainmakerError/methodNotAllowed`` on such a server instead, because an app which does not know the attachment routes routes no other method than `GET` below them, which the server refuses before the app is involved. /// public static var minimumAPIVersion: String { "\(minimumMajorAPIVersion).\(minimumMinorAPIVersion)" @@ -41,7 +45,7 @@ public struct Notes: Capability { /// /// Whether any of the given API versions satisfies ``minimumAPIVersion``. /// - /// The strings are in the `"."` form the server uses, both in the ``apiVersion`` capability and in the `X-Notes-API-Versions` header every response of the notes API carries. Any further components are tolerated and ignored, so a server which one day reports a patch component as well still reads as supported. An entry which does not parse at all is skipped rather than treated as a rejection, so a version scheme this library does not know about cannot make an otherwise supported server look unsupported. + /// The strings are in the `"."` form the server uses, both in the ``apiVersion`` capability and in the `X-Notes-API-Versions` header every response the notes app sends itself carries, which `HTTPURLResponse.notesAPIVersions` reads. Any further components are tolerated and ignored, so a server which one day reports a patch component as well still reads as supported. An entry which does not parse at all is skipped rather than treated as a rejection, so a version scheme this library does not know about cannot make an otherwise supported server look unsupported. /// static func supports(apiVersions: [String]) -> Bool { apiVersions.contains { version in @@ -58,7 +62,7 @@ public struct Notes: Capability { /// /// The versions of the REST API the server supports, e.g. `["0.2", "1.3", "1.4"]`. /// - /// Both retrieval methods require at least ``minimumAPIVersion``, which ``isSupported`` checks for. + /// Every notes feature of ``Server`` requires at least ``minimumAPIVersion``, which ``isSupported`` checks for. A server advertising `"1.3"` as its newest version runs a notes app older than 4.12.3, which those features refuse. /// public let apiVersion: [String]? @@ -66,6 +70,7 @@ public struct Notes: Capability { /// The version of the notes app itself, e.g. `"6.0.1"`. /// /// This is the app's own version and unrelated to the server ``Version``, which is why it is kept as the plain string the server sends. + /// Behaviours the app does not announce through ``apiVersion`` can only be told apart by it, which is what ``isAppVersion(atLeast:)`` is for. /// public let version: String? @@ -86,6 +91,122 @@ public struct Notes: Capability { Self.supports(apiVersions: apiVersion ?? []) } + /// + /// The release of the notes app which started to keep the attachments uploaded through its API in a folder per note and to offer their deletion, which is `"6.1.0"`. + /// + /// Both behaviours are what ``storesAttachmentsPerNote`` and ``supportsAttachmentDeletion`` check for. Neither is announced through ``apiVersion``, which is `1.4` before and after this release, so the app's ``version`` is the only way to tell. + /// + public static let attachmentFoldersAppVersion = "6.1.0" + + /// + /// Whether the installed notes app keeps the attachments uploaded through its API in a hidden `.attachments.` folder next to the note they belong to. + /// + /// This is the case from ``attachmentFoldersAppVersion`` on. Older releases put an uploaded attachment straight into the folder of the note under a random name which only keeps the file extension, which is why a client wanting the attachments of a note to stay apart from its notes may want to check this first. See ``Note/path`` for where the folder is. + /// It is `false` when ``version`` is absent or cannot be compared, see ``isAppVersion(atLeast:)``. + /// + public var storesAttachmentsPerNote: Bool { + isAppVersion(atLeast: Self.attachmentFoldersAppVersion) + } + + /// + /// Whether the installed notes app can delete an attachment of a note through its API. + /// + /// This is the case from ``attachmentFoldersAppVersion`` on, because only attachments in the folder of a note, see ``storesAttachmentsPerNote``, can be told apart from other files and deleted. Older releases do not offer deletion at all and answer such a request with a status which maps to ``RainmakerError/methodNotAllowed``. + /// It is `false` when ``version`` is absent or cannot be compared, see ``isAppVersion(atLeast:)``. + /// + public var supportsAttachmentDeletion: Bool { + isAppVersion(atLeast: Self.attachmentFoldersAppVersion) + } + + /// + /// Whether the installed notes app is at least the given release, comparing it against ``version``. + /// + /// Versions are compared component by component as numbers, so `"6.10.0"` is newer than `"6.9.0"`, and missing components count as zero, so `"7"` equals `"7.0.0"`. A pre-release such as `"6.1.0-beta.3"` is older than the release it precedes, which errs on the side of not relying on a feature which may not have been part of the pre-release yet. Build metadata after a `+` is ignored. + /// The answer is `false` rather than a guess when ``version`` is absent or either version cannot be read that way. + /// + /// This library itself only requires ``minimumAPIVersion``, so whether a feature tied to a release of the app, like ``supportsAttachmentDeletion``, is required is up to the client. + /// + /// - Parameter minimum: The oldest acceptable release of the notes app, e.g. `"6.1.0"`. + /// - Returns: `true` if ``version`` is the same as or newer than `minimum`. + /// + public func isAppVersion(atLeast minimum: String) -> Bool { + guard let version else { + return false + } + + guard let result = Self.compareAppVersions(version, minimum) else { + return false + } + + return result != .orderedAscending + } + + /// + /// Compare two releases of the notes app as ``isAppVersion(atLeast:)`` describes, or return `nil` when either of them cannot be read. + /// + /// The core of each version is what precedes the first `-` or `+`, split at `.` into numbers. On equal cores, a version with a pre-release suffix after `-` orders before one without, while two pre-releases of the same core count as equal because their suffixes are not compared. + /// + static func compareAppVersions(_ lhs: String, _ rhs: String) -> ComparisonResult? { + guard let left = parseAppVersion(lhs) else { + return nil + } + + guard let right = parseAppVersion(rhs) else { + return nil + } + + let count = max(left.core.count, right.core.count) + + for index in 0 ..< count { + let leftComponent = index < left.core.count ? left.core[index] : 0 + let rightComponent = index < right.core.count ? right.core[index] : 0 + + if leftComponent < rightComponent { + return .orderedAscending + } + + if leftComponent > rightComponent { + return .orderedDescending + } + } + + switch (left.isPreRelease, right.isPreRelease) { + case (true, false): + return .orderedAscending + case (false, true): + return .orderedDescending + default: + return .orderedSame + } + } + + /// + /// Split a release of the notes app into the numbers of its core and whether it is a pre-release, as ``compareAppVersions(_:_:)`` compares them, or return `nil` when the core is not made of numbers only. + /// + private static func parseAppVersion(_ version: String) -> (core: [Int], isPreRelease: Bool)? { + let trimmed = version.trimmingCharacters(in: .whitespaces) + let withoutBuild = trimmed.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false).first ?? "" + let parts = withoutBuild.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + let coreText = parts.first ?? "" + let isPreRelease = parts.count > 1 + + var core = [Int]() + + for component in coreText.split(separator: ".", omittingEmptySubsequences: false) { + guard let number = Int(component), number >= 0 else { + return nil + } + + core.append(number) + } + + guard core.isEmpty == false else { + return nil + } + + return (core, isPreRelease) + } + /// /// The keys this capability is decoded from, which are the names the server sends. /// diff --git a/Sources/Rainmaker/Models/NotesSettings.swift b/Sources/Rainmaker/Models/NotesSettings.swift index 92c5858..1fb75fa 100644 --- a/Sources/Rainmaker/Models/NotesSettings.swift +++ b/Sources/Rainmaker/Models/NotesSettings.swift @@ -6,11 +6,13 @@ import Foundation /// /// The settings the notes app keeps for the authenticated user. /// -/// Retrieved through ``Server/notesSettings()``. These describe where and how the app stores notes, which a client needs to know because notes are ordinary files: ``notesPath`` is what makes them reachable over WebDAV, for example through ``Server/enumerate(at:recursively:)->[Item]``, and ``fileSuffix`` is the extension the app gives a note it creates. +/// Retrieved through ``Server/notesSettings()`` and changed through ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)``, which returns them as the server stored them. These describe where and how the app stores notes, which a client needs to know because notes are ordinary files: ``notesPath`` is what makes them reachable over WebDAV, for example through ``Server/enumerate(at:recursively:)->[Item]``, and ``fileSuffix`` is the extension the app gives a note it creates. /// /// The app resolves both lazily and persists them the first time it is asked about notes, so asking the server is the only reliable way to learn them. ``Notes/notesPath`` advertises the same path alongside the capabilities, which spares a second request to a client which fetched those anyway. /// -public struct NotesSettings: Model, Decodable { +/// ``noteMode`` and ``loadsRecentNoteOnStartUp`` are preferences of the app's web interface which a client may honor to feel familiar, while ``showsHiddenFiles`` also decides which notes the server lists. Each of these three is optional, because older releases of the notes app do not send all of them. +/// +public struct NotesSettings: Model, Hashable, Decodable { /// /// The path of the folder the notes are stored in, relative to the account's files, e.g. `"Notes"`. /// @@ -19,9 +21,108 @@ public struct NotesSettings: Model, Decodable { public let notesPath: String /// - /// The file extension the app gives a note it creates, e.g. `".md"`. + /// The file extension the app gives a note it creates, e.g. `".md"`, which is also the default. /// - /// The app reads a note from any file it recognizes regardless of this, so it says what new notes will look like rather than what existing ones do. + /// The app reads notes from files with any extension it recognizes, which are `.txt`, `.org`, `.markdown`, `.md`, `.note` and the custom suffix set most recently, so this says what new notes will look like rather than what existing ones do. + /// The app only recognizes a custom suffix whose characters after the leading dot are all lower-case and contain no further dot, because it compares the lower-cased last extension of a file name with the suffix as stored; with any other custom suffix, such as `.TXT2` or `.tar.gz`, notes created from then on are neither listed nor found by ``Server/note(_:)``, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` or ``Server/deleteNote(_:)``, which throw ``RainmakerError/notFound``. /// public let fileSuffix: String + + /// + /// The way the app's web interface presents a note when it is opened, which corresponds to the server's `noteMode` field. + /// + /// This is `nil` when the server does not send the field or sends a value ``NoteMode`` has no case for, so that a mode introduced by a future release of the notes app cannot fail the lookup of the settings as a whole. + /// + public let noteMode: NoteMode? + + /// + /// Whether the notes app lists files and folders whose names start with a dot as notes and categories, which corresponds to the server's `showHidden` field. + /// + /// This applies to the API as much as to the app's web interface: while it is `false`, which is the default, ``Server/notes()`` and the other listings leave such notes out, although ``Server/note(_:)`` still finds one by its identifier. + /// The notes app sends this since release 6.1.0, so it is `nil` for older releases, which always list such notes. + /// + public let showsHiddenFiles: Bool? + + /// + /// Whether the app's web interface opens the most recently edited note when it starts, which corresponds to the server's `loadRecentOnStartUp` field. + /// + /// The notes app sends this since release 6.1.0, so it is `nil` for older releases. + /// + public let loadsRecentNoteOnStartUp: Bool? + + /// + /// Create settings from their individual values, for example to stand in for a server's response in the tests of a downstream project. + /// + /// - Parameters: + /// - notesPath: The path of the folder the notes are stored in, see ``notesPath``. + /// - fileSuffix: The file extension the app gives a note it creates, see ``fileSuffix``. + /// - noteMode: The way the web interface presents a note, see ``noteMode``. Defaults to `nil`. + /// - showsHiddenFiles: Whether files and folders whose names start with a dot are listed as notes and categories, by the API as well as by the web interface, see ``showsHiddenFiles``. Defaults to `nil`. + /// - loadsRecentNoteOnStartUp: Whether the web interface opens the most recent note when it starts, see ``loadsRecentNoteOnStartUp``. Defaults to `nil`. + /// + public init(notesPath: String, fileSuffix: String, noteMode: NoteMode? = nil, showsHiddenFiles: Bool? = nil, loadsRecentNoteOnStartUp: Bool? = nil) { + self.notesPath = notesPath + self.fileSuffix = fileSuffix + self.noteMode = noteMode + self.showsHiddenFiles = showsHiddenFiles + self.loadsRecentNoteOnStartUp = loadsRecentNoteOnStartUp + } + + /// + /// The keys settings are decoded from, which are the names the server sends. Encoding uses the separate encoding keys below so that the server's naming does not leak into the encoded form. + /// + private enum CodingKeys: String, CodingKey { + case notesPath + case fileSuffix + case noteMode + case showsHiddenFiles = "showHidden" + case loadsRecentNoteOnStartUp = "loadRecentOnStartUp" + } + + // MARK: - Decodable + + /// + /// Decode settings from the server's payload. + /// + /// Only ``notesPath`` and ``fileSuffix`` are required, because every release of the notes app serving ``Notes/minimumAPIVersion`` sends them. The other fields are decoded if present, and ``noteMode`` is read as a plain string first so that a value ``NoteMode`` does not know about leaves it `nil` rather than failing. + /// + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + notesPath = try container.decode(String.self, forKey: .notesPath) + fileSuffix = try container.decode(String.self, forKey: .fileSuffix) + noteMode = try container.decodeIfPresent(String.self, forKey: .noteMode).flatMap(NoteMode.init(rawValue:)) + showsHiddenFiles = try container.decodeIfPresent(Bool.self, forKey: .showsHiddenFiles) + loadsRecentNoteOnStartUp = try container.decodeIfPresent(Bool.self, forKey: .loadsRecentNoteOnStartUp) + } + + // MARK: - Encodable + + /// + /// The keys settings are encoded under, which are the property names rather than the names the server sends. + /// + /// Encoding deliberately does not reuse ``CodingKeys``, for the same reason ``Note`` keeps its encoding keys apart: the server's `showHidden` and `loadRecentOnStartUp` would otherwise leak into the encoded form in place of ``showsHiddenFiles`` and ``loadsRecentNoteOnStartUp``. + /// + private enum EncodingKeys: String, CodingKey { + case notesPath + case fileSuffix + case noteMode + case showsHiddenFiles + case loadsRecentNoteOnStartUp + } + + /// + /// Encode settings under their property names, so that the encoded form mirrors this type rather than the server's payload. + /// + /// Absent values are encoded as `null` rather than left out, so that the encoded form always has the same keys. + /// + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + + try container.encode(notesPath, forKey: .notesPath) + try container.encode(fileSuffix, forKey: .fileSuffix) + try container.encode(noteMode, forKey: .noteMode) + try container.encode(showsHiddenFiles, forKey: .showsHiddenFiles) + try container.encode(loadsRecentNoteOnStartUp, forKey: .loadsRecentNoteOnStartUp) + } } diff --git a/Sources/Rainmaker/Models/ServerEventOptions.swift b/Sources/Rainmaker/Models/ServerEventOptions.swift index 1a3042f..baaed9a 100644 --- a/Sources/Rainmaker/Models/ServerEventOptions.swift +++ b/Sources/Rainmaker/Models/ServerEventOptions.swift @@ -4,9 +4,9 @@ import Foundation /// -/// Configures which server-side changes ``Server/events(_:)`` observes and how often it polls. +/// Configures which server-side changes ``Server/events(_:)`` observes, over which ``ServerEventTransport`` and how often it polls. /// -/// The defaults observe every ``ServerSubject`` and poll every 30 seconds when the `notify_push` WebSocket is unavailable, matching the cadence the official Nextcloud clients use. +/// The defaults observe every ``ServerSubject`` with the ``ServerEventTransport/automatic`` transport and poll every 30 seconds when the `notify_push` WebSocket is unavailable, matching the cadence the official Nextcloud clients use. /// public struct ServerEventOptions: Sendable { /// @@ -17,7 +17,7 @@ public struct ServerEventOptions: Sendable { public var subjects: Set /// - /// The interval in seconds at which subjects are polled while the WebSocket is unavailable, or for subjects the server does not push. + /// The interval in seconds at which subjects are polled while the WebSocket is unavailable or not used, as with the ``ServerEventTransport/polling`` transport and on watchOS, or for subjects the server does not push. /// public var pollInterval: TimeInterval @@ -38,21 +38,30 @@ public struct ServerEventOptions: Sendable { /// public var emitConnectedOnStart: Bool + /// + /// How changes are learned about: over the `notify_push` WebSocket when possible, or only by polling. + /// + /// With ``ServerEventTransport/polling`` the subjects are polled at ``pollInterval``, and ``backstopPollInterval`` and ``listenFileIDs`` have no effect because they concern the WebSocket. + /// + public var transport: ServerEventTransport + /// /// Create a new set of options. /// /// - Parameters: /// - subjects: The subjects to observe. Defaults to all of them. - /// - pollInterval: The polling interval in seconds used when the WebSocket is unavailable. Defaults to 30. + /// - pollInterval: The polling interval in seconds used when the WebSocket is unavailable or not used. Defaults to 30. /// - backstopPollInterval: The backstop polling interval in seconds used while the WebSocket is connected. Defaults to 900. /// - listenFileIDs: Whether to request per-file identifiers. Defaults to `false`. /// - emitConnectedOnStart: Whether to emit ``ServerEvent/connected`` on subscription. Defaults to `true`. + /// - transport: How changes are learned about. Defaults to ``ServerEventTransport/automatic``. /// - public init(subjects: Set = Set(ServerSubject.allCases), pollInterval: TimeInterval = 30, backstopPollInterval: TimeInterval = 900, listenFileIDs: Bool = false, emitConnectedOnStart: Bool = true) { + public init(subjects: Set = Set(ServerSubject.allCases), pollInterval: TimeInterval = 30, backstopPollInterval: TimeInterval = 900, listenFileIDs: Bool = false, emitConnectedOnStart: Bool = true, transport: ServerEventTransport = .automatic) { self.subjects = subjects self.pollInterval = pollInterval self.backstopPollInterval = backstopPollInterval self.listenFileIDs = listenFileIDs self.emitConnectedOnStart = emitConnectedOnStart + self.transport = transport } } diff --git a/Sources/Rainmaker/Models/ServerEventTransport.swift b/Sources/Rainmaker/Models/ServerEventTransport.swift new file mode 100644 index 0000000..1a97ea5 --- /dev/null +++ b/Sources/Rainmaker/Models/ServerEventTransport.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// How ``Server/events(_:)`` learns about server-side changes, chosen through ``ServerEventOptions/transport``. +/// +/// Both transports deliver the same ``ServerEvent`` re-fetch hints, so the choice only affects how promptly they arrive and what the stream costs while it runs, not how a consumer handles them. +/// The raw values are the names the command line utility's `watch --transport` option accepts. +/// +public enum ServerEventTransport: String, Sendable, Hashable, CaseIterable { + /// + /// Prefer the `notify_push` WebSocket when the server advertises it in its ``PushNotifications`` capability and the platform supports it, and poll otherwise. + /// + /// This is the default. On watchOS it always polls, because the system only grants WebSocket connections to apps in narrow circumstances, such as an active audio streaming session, as Apple's technote TN3135 on low-level networking on watchOS describes. + /// It also polls for a while after the WebSocket repeatedly fails to connect, see ``Server/events(_:)``. + /// + case automatic + + /// + /// Only poll every subject at ``ServerEventOptions/pollInterval``, without ever looking up the server's capabilities or opening a WebSocket. + /// + /// This suits a consumer that only needs a periodic re-fetch hint, or one running where a long-lived socket is unwanted, such as a short-lived extension. Because polling sends no requests of its own, a stream polling this way does not notice credentials the server rejects; the consumer learns about them from the requests it makes in response to the hints. + /// + case polling +} diff --git a/Sources/Rainmaker/Models/ShareType.swift b/Sources/Rainmaker/Models/ShareType.swift new file mode 100644 index 0000000..b4842d8 --- /dev/null +++ b/Sources/Rainmaker/Models/ShareType.swift @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// A kind of share a file can be part of on the server. +/// +/// This is what ``Note/shareTypes`` lists for a note, one entry per kind of share the note's file is part of, and what ``Note/isShared`` summarizes. +/// +/// The kinds are those of the server's sharing subsystem, whose raw values are neither consecutive nor self-explanatory, which is why the known ones are offered as named constants such as ``user`` and ``link``. +/// Unlike ``ConversationType`` this is a struct wrapping the plain number rather than an enum: a kind introduced by a future version of the server or by an app still decodes and keeps its number, and comparing against the constants keeps working without a catch-all case to switch over. +/// +public struct ShareType: RawRepresentable, Model, Hashable, Decodable, CustomStringConvertible { + /// + /// The number the server uses for this kind of share. + /// + public let rawValue: Int + + /// + /// Wrap the number the server uses for a kind of share, whether or not this library has a named constant for it. + /// + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// + /// A share with an individual user of the same server, equalling the raw representation as `0`. + /// + public static let user = ShareType(rawValue: 0) + + /// + /// A share with a group of users of the same server, equalling the raw representation as `1`. + /// + public static let group = ShareType(rawValue: 1) + + /// + /// A public link share, equalling the raw representation as `3`. + /// + public static let link = ShareType(rawValue: 3) + + /// + /// A share with an email address, equalling the raw representation as `4`. + /// + public static let email = ShareType(rawValue: 4) + + /// + /// A federated share with a user of another server, equalling the raw representation as `6`. + /// + public static let federated = ShareType(rawValue: 6) + + /// + /// A share with a Nextcloud Talk conversation, equalling the raw representation as `10`. + /// + public static let room = ShareType(rawValue: 10) + + /// + /// A share with a board of the Deck app, equalling the raw representation as `12`. + /// + public static let deck = ShareType(rawValue: 12) + + /// + /// A share through the ScienceMesh federation, equalling the raw representation as `15`. + /// + public static let scienceMesh = ShareType(rawValue: 15) + + // MARK: - Decodable + + /// + /// Decode a kind of share from the plain number the server sends for it. + /// + /// Every number decodes, including those without a named constant, so that a kind of share this library does not know about cannot fail the decoding of a whole ``Note``. + /// + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(rawValue: container.decode(Int.self)) + } + + // MARK: - Encodable + + /// + /// Encode a kind of share as the plain number the server uses for it. + /// + /// Unlike ``ConversationType/encode(to:)`` this does not encode a name, because a struct wrapping a number has no case name to fall back on and the number is the only representation every value has. + /// + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + // MARK: - CustomStringConvertible + + /// + /// Implementation for `CustomStringConvertible` conformance to have a concise and human-readable textual representation of a kind of share. + /// + public var description: String { + switch self { + case .user: + "user" + case .group: + "group" + case .link: + "link" + case .email: + "email" + case .federated: + "federated" + case .room: + "room" + case .deck: + "deck" + case .scienceMesh: + "scienceMesh" + default: + "\(rawValue)" + } + } +} diff --git a/Sources/Rainmaker/Models/User.swift b/Sources/Rainmaker/Models/User.swift index cbb82cf..16b34c6 100644 --- a/Sources/Rainmaker/Models/User.swift +++ b/Sources/Rainmaker/Models/User.swift @@ -2,16 +2,33 @@ // SPDX-License-Identifier: MIT /// -/// A simple user description as used in file system information. +/// A simple user description as used in file system information and returned by ``Server/currentUser()``. +/// +/// ``Item/owner`` and the owners of a ``Lock`` are users, and ``Server/currentUser()`` describes the account a ``Server`` authenticates as. /// public struct User: Model, Hashable, Identifiable { /// /// The user account identifier unique on the server. /// + /// It is not necessarily the name the account logs in with, which ``Server/user`` and ``LoginResult/name`` hold, because a server can accept an email address or a login attribute of an LDAP directory as the login name. + /// This identifier is what ``Server/userAvatar(_:size:darkTheme:)`` expects. + /// public let id: String /// /// The display name of the user account. /// public let displayName: String + + /// + /// Create a user from its parts, for example to fake one in a test or a preview. + /// + /// - Parameters: + /// - id: The user account identifier unique on the server. + /// - displayName: The display name of the user account. + /// + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } } diff --git a/Sources/Rainmaker/NoteChangeSet.swift b/Sources/Rainmaker/NoteChangeSet.swift new file mode 100644 index 0000000..36a6579 --- /dev/null +++ b/Sources/Rainmaker/NoteChangeSet.swift @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// What ``NoteChanges`` and ``NoteSummaryChanges`` have in common, which is what lets the listings of notes with and without their content share one implementation of their requests, of reading a response into a result and of a chunked pass. +/// +/// The two listings differ in nothing but whether the request leaves out the content of the notes it sends in full, which ``excludesContent`` says, and in the type those notes are decoded as, which ``Item`` names. +/// `Server.makeNoteChanges(from:response:)` reads a response into any conforming type and ``NoteChunkPass`` unfolds a stream of chunks of any of them, so the public listings stay concrete and documented one by one while their logic exists only once. +/// It is internal on purpose: a downstream project only ever sees the two concrete types, which conform to it through `Extensions/NoteChanges+NoteChangeSet.swift` and `Extensions/NoteSummaryChanges+NoteChangeSet.swift`. +/// +protocol NoteChangeSet: Sendable { + /// + /// The type a note sent in full is decoded as, which is ``Note`` for ``NoteChanges`` and ``NoteSummary`` for ``NoteSummaryChanges``. + /// + associatedtype Item: Decodable + + /// + /// Whether the request for this kind of listing asks the server to leave out the content of the notes it sends in full, which is sent as the `exclude` parameter by `Server.makeNoteChangesRequest(changedSince:excludesContent:chunkSize:chunkCursor:headerFields:)`. + /// + /// It is a property of the result type rather than an argument of each call, so that a listing whose notes are decoded as ``Note`` can never ask for notes without the content ``Note`` requires. + /// + static var excludesContent: Bool { get } + + /// + /// The opaque position to continue a chunked retrieval from, which ``NoteChunkPass`` hands to the next request, or `nil` for the last chunk. + /// + var chunkCursor: String? { get } + + /// + /// Create a listing from the notes the server sent in full, the identifiers of those it did not, and what the headers of the response said. + /// + /// - Parameters: + /// - changed: The notes sent in full. + /// - unchanged: The identifiers of the notes sent as identifiers alone. + /// - lastModified: The moment read from the `Last-Modified` header. + /// - entityTag: The entity tag read from the `ETag` header, without quotes. + /// - chunkCursor: The cursor read from the `X-Notes-Chunk-Cursor` header. + /// - pendingCount: The number read from the `X-Notes-Chunk-Pending` header. + /// + init(changed: [Item], unchanged: [Int], lastModified: Date?, entityTag: String?, chunkCursor: String?, pendingCount: Int?) +} diff --git a/Sources/Rainmaker/NoteChunkPass.swift b/Sources/Rainmaker/NoteChunkPass.swift new file mode 100644 index 0000000..42d5dcd --- /dev/null +++ b/Sources/Rainmaker/NoteChunkPass.swift @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The state of one chunked pass over the notes which changed since a given moment, which ``Server/noteChunks(changedSince:chunkSize:)`` and ``Server/noteSummaryChunks(changedSince:chunkSize:)`` unfold their streams from. +/// +/// Each call to ``next()`` retrieves one chunk through the closure the pass was created with, which is ``Server/notes(changedSince:chunkSize:continuingAfter:)`` or ``Server/noteSummaries(changedSince:chunkSize:continuingAfter:)`` with the moment and the chunk size of the pass, continuing from the ``NoteChangeSet/chunkCursor`` of the chunk before, until a chunk has no cursor or a request fails. +/// It is generic over the ``NoteChangeSet`` a chunk is, so that both streams share the same rules for when a pass is over. +/// It is an actor because the stream's unfolding closure is `@Sendable` while the cursor changes from one chunk to the next. +/// The requests run within the task which awaits ``next()``, which is the task iterating the stream, so cancelling that task cancels the request in flight. +/// +actor NoteChunkPass { + /// + /// Retrieve the chunk following the given cursor, or the first chunk of the pass for `nil`, always with the moment and the chunk size the pass was started with, because the server prunes each chunk by that moment. + /// + private let retrieve: @Sendable (_ cursor: String?) async throws -> Changes + + /// + /// The ``NoteChangeSet/chunkCursor`` of the chunk retrieved last, which the next chunk continues from, or `nil` before the first chunk. + /// + private var cursor: String? + + /// + /// Whether the pass is over, because the last chunk was retrieved or a request failed, after which ``next()`` sends no further request. + /// + private var isFinished = false + + /// + /// Create a pass which has not retrieved any chunk yet. + /// + /// - Parameters: + /// - retrieve: Retrieve the chunk following the given cursor, or the first chunk for `nil`, with the moment and the chunk size of the pass. + /// + init(retrieve: @escaping @Sendable (_ cursor: String?) async throws -> Changes) { + self.retrieve = retrieve + } + + /// + /// Retrieve the next chunk of the pass, or `nil` once the pass is over. + /// + /// The pass is over after a chunk without a ``NoteChangeSet/chunkCursor``, which is the complete one, and after the first error, which is rethrown, so the stream built on this finishes either way. + /// + /// - Throws: `CancellationError` when the calling task is cancelled before the request is sent, ``RainmakerError/responseDecodingFailed(reason:)`` when the server answers with the cursor it was asked to continue from, and otherwise whatever ``retrieve`` throws. + /// + func next() async throws -> Changes? { + guard isFinished == false else { + return nil + } + + // Ending the pass before any error propagates means no later call can send another request for it. + isFinished = true + try Task.checkCancellation() + + let sentCursor = cursor + let chunk = try await retrieve(sentCursor) + + guard let nextCursor = chunk.chunkCursor else { + return chunk + } + + // The server orders the notes it sends and every cursor points past the last note of its chunk, so a cursor which does not advance means the server ignores it and would answer with the same chunk forever. + guard nextCursor != sentCursor else { + throw RainmakerError.responseDecodingFailed(reason: "The server answered a chunk of notes with the cursor it was asked to continue from.") + } + + cursor = nextCursor + isFinished = false + + return chunk + } +} diff --git a/Sources/Rainmaker/NoteContent/NoteAttachmentPath.swift b/Sources/Rainmaker/NoteContent/NoteAttachmentPath.swift new file mode 100644 index 0000000..349a4af --- /dev/null +++ b/Sources/Rainmaker/NoteContent/NoteAttachmentPath.swift @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +/// +/// The rules by which the notes app resolves the path of an attachment, which is relative to the folder of a note's category, to the file it names, for a client which wants to know which file ``Server/attachment(at:ofNote:)`` would return without asking the server. +/// +/// This is useful for a client keeping attachments in a store of its own, which can key them by the resolved path so that two notes referencing the same file share one entry, and for one which reaches the files over WebDAV, see ``Server/download(_:to:force:)``, below the folder ``NotesSettings/notesPath`` names. +/// ``resolve(_:relativeTo:)`` is pure, so it needs neither a ``Server`` nor a connection, and it can run on any thread, but it cannot tell whether a file exists at the path. +/// +public enum NoteAttachmentPath { + /// + /// Resolve the path of an attachment, as ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returns it or as ``NoteAttachmentReference/decode(_:)`` reads it from a note's content, to a path relative to the notes folder. + /// + /// The path is read relative to the folder of the note's category, starting from the components of the category: + /// + /// - Every `\` in the path counts as `/`, while one in the category does not. + /// - Empty components, such as those of a leading, a trailing or a doubled `/`, are skipped. + /// - A `..` takes the component before it back, but never steps above the notes folder, so `../../x` of a note in the category `Work` resolves to `x`. + /// - A `.` is dropped from the result, but only after the path was resolved, because it counts as a component a following `..` takes back, so `Work` and `a/./../b` resolve to `Work/a/b`. + /// - Any other component is appended as it is, without changing its percent-encoding or its Unicode normalization form. + /// + /// - Parameters: + /// - path: The path of the attachment relative to the folder of the note's category, which must not be percent-encoded anymore. + /// - category: The ``Note/category`` of the note, or an empty string for a note under no category. + /// + /// - Returns: The path of the attachment relative to the notes folder, with `/` delimiting its components, or `nil` when it resolves to the notes folder itself, which never is an attachment. + /// + public static func resolve(_ path: String, relativeTo category: String) -> String? { + var components = category.unicodeScalars.split(separator: "/", omittingEmptySubsequences: true).map { String(String.UnicodeScalarView($0)) } + let normalizedPath: [Unicode.Scalar] = path.unicodeScalars.map { $0 == "\\" ? "/" : $0 } + + for component in normalizedPath.split(separator: "/", omittingEmptySubsequences: true) { + if component.elementsEqual([".", "."]) { + _ = components.popLast() + } else { + components.append(String(String.UnicodeScalarView(component))) + } + } + + let resolved = components.filter { $0.unicodeScalars.elementsEqual(["."]) == false } + + guard resolved.isEmpty == false else { + return nil + } + + return resolved.joined(separator: "/") + } +} diff --git a/Sources/Rainmaker/NoteContent/NoteAttachmentReference.swift b/Sources/Rainmaker/NoteContent/NoteAttachmentReference.swift new file mode 100644 index 0000000..acd661c --- /dev/null +++ b/Sources/Rainmaker/NoteContent/NoteAttachmentReference.swift @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The way a note's Markdown content references an attachment, as the Nextcloud Text app, which is the notes app's rich editor, writes and reads such references. +/// +/// Adding an attachment through ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` does not change the note, it only stores the file and returns its path relative to the folder of the note's category, which releases of the notes app keeping attachments per note, see ``Notes/storesAttachmentsPerNote``, shape like `.attachments./`. +/// To embed the file, a caller inserts a reference such as the one ``markdown(alt:path:)`` builds into the note's content and changes the note through ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``. +/// Inserting it matters beyond showing the file: once a Markdown note has been opened in Text, a background job of Text deletes, whenever nobody is editing the note, every file in the note's `.attachments.` folder which the content does not reference as `![…](.attachments./)`, comparing the name it decodes from the reference with the file name, so an attachment which is not referenced, or referenced with another encoding, is lost. +/// In the other direction, ``decode(_:)`` turns the target of a reference back into the path ``Server/attachment(at:ofNote:)`` takes, and ``NoteAttachmentPath/resolve(_:relativeTo:)`` says which file in the notes folder it names. +/// +/// All of these are pure, so they need neither a ``Server`` nor a connection, and they can run on any thread. +/// +public enum NoteAttachmentReference { + /// + /// The invisible characters which control the direction of text and which Text removes from a file name before it references the file, so that a name cannot pretend to have another extension than it has, which are U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069. + /// + static let bidirectionalControls: Set = [ + "\u{200E}", "\u{200F}", + "\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}", + "\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}", + ] + + /// + /// The digits of a hexadecimal number in upper case, as ``encode(_:)`` writes an escaped byte. + /// + static let hexadecimalDigits: [Unicode.Scalar] = Array("0123456789ABCDEF".unicodeScalars) + + /// + /// Whether ``encode(_:)`` writes the byte as it is, which only the ASCII letters and digits and `-`, `_`, `.` and `~` are. + /// + static func isUnreserved(_ byte: UInt8) -> Bool { + switch byte { + case UInt8(ascii: "A") ... UInt8(ascii: "Z"), UInt8(ascii: "a") ... UInt8(ascii: "z"), UInt8(ascii: "0") ... UInt8(ascii: "9"), UInt8(ascii: "-"), UInt8(ascii: "_"), UInt8(ascii: "."), UInt8(ascii: "~"): + true + default: + false + } + } + + /// + /// The value of a hexadecimal digit in either case, or `nil` when the scalar is no such digit, which is how ``decode(_:)`` tells an escaped byte from a `%` which is meant literally. + /// + static func hexadecimalValue(of scalar: Unicode.Scalar) -> UInt8? { + switch scalar { + case "0" ... "9": + UInt8(scalar.value - Unicode.Scalar("0").value) + case "A" ... "F": + UInt8(scalar.value - Unicode.Scalar("A").value + 10) + case "a" ... "f": + UInt8(scalar.value - Unicode.Scalar("a").value + 10) + default: + nil + } + } + + /// + /// Percent-encode a path relative to the folder of a note's category for use as the target of a Markdown reference in the note's content. + /// + /// Each component the path's `/` delimit is encoded on its own, keeping the `/` between them, so `.attachments.123/Photo (1).png` becomes `.attachments.123/Photo%20%281%29.png`. + /// Every byte of a component's UTF-8 representation except the ASCII letters and digits and `-`, `_`, `.` and `~` is written as `%` followed by two upper case hexadecimal digits. + /// That is what JavaScript's `encodeURIComponent` does plus escaping `!`, `'`, `(`, `)` and `*`, which is how Text writes references and what it expects when it looks for them: a `)` would end the Markdown link early, an `&` ends the name Text reads, and a `+` would be read back as a space. + /// + /// - Parameter relativePath: The path as ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returned it. + /// + /// - Returns: The percent-encoded path. + /// + public static func encode(_ relativePath: String) -> String { + var encoded = String.UnicodeScalarView() + + for byte in relativePath.utf8 { + if byte == UInt8(ascii: "/") || isUnreserved(byte) { + encoded.append(Unicode.Scalar(byte)) + } else { + encoded.append("%") + encoded.append(hexadecimalDigits[Int(byte >> 4)]) + encoded.append(hexadecimalDigits[Int(byte & 0x0F)]) + } + } + + return String(encoded) + } + + /// + /// Decode the target of a Markdown reference in a note's content into the path ``Server/attachment(at:ofNote:)`` and ``NoteAttachmentPath/resolve(_:relativeTo:)`` take. + /// + /// Every `%` followed by two hexadecimal digits in either case stands for the byte they spell, and the bytes are read as UTF-8. + /// A `+` stays a `+`, as it does for the notes app's own Markdown preview, which reads references with JavaScript's `decodeURIComponent`. + /// References written by hand or by older editors are often not encoded at all, so a `%` which is not followed by two hexadecimal digits is kept as it is, and a reference whose decoded bytes are not valid UTF-8 is returned unchanged rather than failing. + /// + /// - Parameter reference: The target of the reference, which is what stands between the parentheses of `![…](…)`. + /// + /// - Returns: The decoded path, relative to the folder of the note's category. + /// + public static func decode(_ reference: String) -> String { + let scalars = Array(reference.unicodeScalars) + var bytes: [UInt8] = [] + bytes.reserveCapacity(scalars.count) + var index = 0 + + while index < scalars.count { + if scalars[index] == "%", index + 2 < scalars.count, let high = hexadecimalValue(of: scalars[index + 1]), let low = hexadecimalValue(of: scalars[index + 2]) { + bytes.append(high << 4 | low) + index += 3 + continue + } + + bytes.append(contentsOf: String(scalars[index]).utf8) + index += 1 + } + + return String(bytes: bytes, encoding: .utf8) ?? reference + } + + /// + /// Build the Markdown which embeds an attachment into a note's content the way Text does, such as `![Photo (1).png](.attachments.123/Photo%20%281%29.png)`. + /// + /// The invisible characters which control the direction of text, see ``sanitizeFileName(_:)``, are removed from the file name, which is the last component of the path, before it is encoded through ``encode(_:)``, as Text does. + /// Text compares the name it decodes from a reference with the name of the file, so a file whose stored name contains such characters is not recognized by the reference built here and would be deleted by Text's background job, which is why a file name is best passed through ``sanitizeFileName(_:)`` before it is uploaded. + /// The alternative text loses `[` and `]`, which would end it early, as well as those invisible characters, and line breaks in it become spaces, so that it cannot split the reference. + /// + /// - Parameters: + /// - alt: The alternative text which describes the attachment, for which Text uses the file name. + /// - path: The path of the attachment relative to the folder of the note's category, as ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returned it. + /// + /// - Returns: The Markdown of the reference, without a line break before or after it. + /// + public static func markdown(alt: String, path: String) -> String { + var components = path.unicodeScalars.split(separator: "/", omittingEmptySubsequences: false).map { String(String.UnicodeScalarView($0)) } + + if let last = components.indices.last { + components[last] = sanitizeFileName(components[last]) + } + + var text = String.UnicodeScalarView() + + for scalar in alt.unicodeScalars where scalar != "[" && scalar != "]" && bidirectionalControls.contains(scalar) == false { + text.append(NoteNameSanitizer.isLineBreak(scalar) ? " " : scalar) + } + + return "![\(String(text))](\(encode(components.joined(separator: "/"))))" + } + + /// + /// Remove the invisible characters which control the direction of text from a file name, as Text does before it references a file. + /// + /// Such characters can make a name appear to have another extension than it has, which is why Text leaves them out of references, see ``markdown(alt:path:)``. + /// Passing a file name through this before handing it to ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` makes the name the server stores the file under match the name a reference to it decodes to. + /// + /// - Parameter fileName: The file name to sanitize. + /// + /// - Returns: The file name without the characters U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069. + /// + public static func sanitizeFileName(_ fileName: String) -> String { + String(String.UnicodeScalarView(fileName.unicodeScalars.filter { bidirectionalControls.contains($0) == false })) + } +} diff --git a/Sources/Rainmaker/NoteContent/NoteCategory.swift b/Sources/Rainmaker/NoteContent/NoteCategory.swift new file mode 100644 index 0000000..19f0159 --- /dev/null +++ b/Sources/Rainmaker/NoteContent/NoteCategory.swift @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +/// +/// The rules by which the notes app turns a category into the folder below the notes folder that a note's file is placed in, for a client which wants to know the ``Note/category`` a note will have before or without asking the server. +/// +/// The category of a note is the path of the folder its file is in, relative to the notes folder ``NotesSettings/notesPath`` names, so the notes app sanitizes every category it is given to ``Server/createNote(title:category:content:modification:isFavorite:)`` or ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``. +/// ``sanitize(_:)`` is pure, so it needs neither a ``Server`` nor a connection, and it can run on any thread, but a caller still adopts the ``Note/category`` the server returns, for the reasons ``NoteTitle`` lists. +/// +public enum NoteCategory { + /// + /// Sanitize a category, such as one a user typed, as the notes app does before it creates the folders it names. + /// + /// The category is split into its components at every `/`, and each component is sanitized like a title in ``NoteTitle/sanitize(_:)``, except that it is neither cut to its first line nor cut off after a number of characters, and its white space is left as it is apart from trimming: + /// + /// - The characters `*`, `|`, `/`, `\`, `:`, `"`, `<`, `>` and `?` are removed, so a `\` does not separate components but disappears. + /// - Dots and white space at the start of each line of a component are removed, so that the folder is not hidden, which also turns `..` into nothing and keeps a category from reaching outside the notes folder. + /// - Spaces, tabs, line feeds and carriage returns are trimmed from both ends of a component. + /// + /// Components which end up empty are dropped, and the rest are joined with `/` again, so `/Work//Projects/` becomes `Work/Projects` and `Work/../Private` becomes `Work/Private`. + /// + /// - Parameter category: The category to sanitize, with `/` delimiting sub-categories. + /// + /// - Returns: The sanitized category, or an empty string when nothing remains of it, which files a note under no category at all. + /// + public static func sanitize(_ category: String) -> String { + let components = category.unicodeScalars.split(separator: "/", omittingEmptySubsequences: false) + let sanitized = components.map { NoteNameSanitizer.sanitize(Array($0)) } + + return sanitized + .filter { $0.isEmpty == false } + .map { String(String.UnicodeScalarView($0)) } + .joined(separator: "/") + } +} diff --git a/Sources/Rainmaker/NoteContent/NoteMarkdownStripper.swift b/Sources/Rainmaker/NoteContent/NoteMarkdownStripper.swift new file mode 100644 index 0000000..9f34549 --- /dev/null +++ b/Sources/Rainmaker/NoteContent/NoteMarkdownStripper.swift @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +/// +/// The removal of the Markdown syntax the notes app removes from a note's content before it takes a title from it, which is what ``NoteTitle/derive(fromContent:)`` builds on. +/// +/// It runs four passes over the whole content, one after the other, each of which scans the content once from its start and continues behind whatever it removed: +/// +/// 1. A list marker, which is `*`, `+` or `-` followed by white space at the start of a line, is removed along with the white space around it. +/// 2. A heading, which is a line starting with one or more `#` followed by white space, is reduced to its text without the trailing white space and `#`. +/// 3. A line which consists of nothing but `=` or nothing but `-`, which underlines a heading in the other Markdown syntax for headings, is emptied. +/// 4. Emphasis, which is text between two equal runs of `*` or `_` on one line, is reduced to the text between them. +/// +/// Every pass but the one removing emphasis takes time proportional to the length of the content, and that one does as well for realistic content, while its worst case, a line of runs of delimiters which get shorter one after the other, grows with the length of the line times its square root, so that even content made to be pathological, such as a heading followed by a long run of spaces, cannot stall a caller. +/// Like ``NoteNameSanitizer``, it works on Unicode scalars, considers white space what ``NoteNameSanitizer/isWhitespace(_:)`` does, which includes line feeds, and starts a new line only after a line feed, so that a carriage return of a Windows line ending is part of the line it ends. +/// +enum NoteMarkdownStripper { + /// + /// The content with the four passes described above applied. + /// + static func strip(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + let withoutListMarkers = removingListMarkers(scalars) + let withoutHeadingMarkers = removingHeadingMarkers(withoutListMarkers) + let withoutUnderlines = removingUnderlines(withoutHeadingMarkers) + + return removingEmphasis(withoutUnderlines) + } + + /// + /// Whether a line starts at the index, which is the case at the very start and right behind every line feed. + /// + private static func startsLine(_ scalars: [Unicode.Scalar], at index: Int) -> Bool { + index == 0 || scalars[index - 1] == "\n" + } + + /// + /// The index of the first scalar at or after `index` which is not white space, or the end of the scalars. + /// + private static func endOfWhitespace(_ scalars: [Unicode.Scalar], from index: Int) -> Int { + var end = index + + while end < scalars.count, NoteNameSanitizer.isWhitespace(scalars[end]) { + end += 1 + } + + return end + } + + /// + /// The index of the first scalar at or after `index` which differs from `scalar`, or the end of the scalars. + /// + private static func endOfRun(of scalar: Unicode.Scalar, in scalars: [Unicode.Scalar], from index: Int) -> Int { + var end = index + + while end < scalars.count, scalars[end] == scalar { + end += 1 + } + + return end + } + + /// + /// Whether a line ends at the index, which is the case at the very end and right before every line feed. + /// + private static func endsLine(_ scalars: [Unicode.Scalar], at index: Int) -> Bool { + index == scalars.count || scalars[index] == "\n" + } + + /// + /// The first pass of ``strip(_:)``, which removes list markers along with the white space before and after them. + /// + /// The white space before a marker may span blank lines, and the white space after it may span line breaks, so a marker alone on its line takes the line break and the indentation of the next line with it. + /// + private static func removingListMarkers(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + var result: [Unicode.Scalar] = [] + result.reserveCapacity(scalars.count) + var index = 0 + + while index < scalars.count { + if startsLine(scalars, at: index) { + let marker = endOfWhitespace(scalars, from: index) + + if marker + 1 < scalars.count, ["*", "+", "-"].contains(scalars[marker]), NoteNameSanitizer.isWhitespace(scalars[marker + 1]) { + index = endOfWhitespace(scalars, from: marker + 1) + continue + } + + // Every line starting within the white space ends it at the same place and fails alike, so it is kept as a whole. + if marker > index { + result.append(contentsOf: scalars[index ..< marker]) + index = marker + continue + } + } + + result.append(scalars[index]) + index += 1 + } + + return result + } + + /// + /// The second pass of ``strip(_:)``, which reduces headings to their text. + /// + /// The text of a heading is the shortest one after which nothing but white space and `#` remain up to the end of a line, and whatever follows it up to there is removed as well. + /// The white space which separates the `#` from the text may span line breaks, so a `#` alone on its line turns the next line into the heading. + /// + private static func removingHeadingMarkers(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + var result: [Unicode.Scalar] = [] + result.reserveCapacity(scalars.count) + var index = 0 + + while index < scalars.count { + if startsLine(scalars, at: index), scalars[index] == "#" { + let separator = endOfRun(of: "#", in: scalars, from: index) + + if separator < scalars.count, NoteNameSanitizer.isWhitespace(scalars[separator]) { + let textStart = endOfWhitespace(scalars, from: separator) + var textEnd = textStart + + while true { + if let end = endOfHeadingClosing(scalars, from: textEnd) { + result.append(contentsOf: scalars[textStart ..< textEnd]) + index = end + break + } + + // A closing starting anywhere within the same white space and `#` fails alike, so the text grows past them at once, and as the closing always matches at the end of a line, the text never grows past it. + textEnd = max(textEnd + 1, endOfRun(of: "#", in: scalars, from: endOfWhitespace(scalars, from: textEnd))) + } + + continue + } + } + + result.append(scalars[index]) + index += 1 + } + + return result + } + + /// + /// The end of what closes a heading whose text ends at `index`, which is white space followed by `#` followed by the end of a line, preferring as much white space and then as many `#` as possible, or `nil` when nothing closes it there. + /// + /// Taking less white space than there is only helps when the white space includes a line feed, at which a line ends, so the last line feed within it closes the heading when the `#` after all of it do not. + /// + private static func endOfHeadingClosing(_ scalars: [Unicode.Scalar], from index: Int) -> Int? { + let hashes = endOfWhitespace(scalars, from: index) + let end = endOfRun(of: "#", in: scalars, from: hashes) + + if endsLine(scalars, at: end) { + return end + } + + return scalars[index ..< hashes].lastIndex(of: "\n") + } + + /// + /// The third pass of ``strip(_:)``, which empties lines consisting of nothing but `=` or nothing but `-` while keeping their line breaks. + /// + private static func removingUnderlines(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + var result: [Unicode.Scalar] = [] + result.reserveCapacity(scalars.count) + var index = 0 + + while index < scalars.count { + if startsLine(scalars, at: index), scalars[index] == "=" || scalars[index] == "-" { + let end = endOfRun(of: scalars[index], in: scalars, from: index) + + if endsLine(scalars, at: end) { + index = end + continue + } + } + + result.append(scalars[index]) + index += 1 + } + + return result + } + + /// + /// The fourth pass of ``strip(_:)``, which reduces emphasized text to the text between its delimiters. + /// + /// A run of `*` or `_` opens emphasis wherever it is, even within a word, and the same run closes it at the nearest place on the same line, so `snake_case_name` loses its underscores. + /// When no run of the full length closes it, shorter runs are tried, so `***a**` keeps one `*` and yields `*a`, and the opening run may close itself, so `****` yields nothing. + /// + private static func removingEmphasis(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + var result: [Unicode.Scalar] = [] + result.reserveCapacity(scalars.count) + var index = 0 + + while index < scalars.count { + let delimiter = scalars[index] + + if delimiter == "*" || delimiter == "_", let emphasis = emphasis(of: delimiter, in: scalars, at: index) { + result.append(contentsOf: scalars[index + emphasis.length ..< emphasis.closing]) + index = emphasis.closing + emphasis.length + continue + } + + result.append(delimiter) + index += 1 + } + + return result + } + + /// + /// The length of the delimiters and the index of the closing delimiter of the emphasis the run of `delimiter` at `index` opens, or `nil` when it opens none. + /// + /// The longest delimiter which is closed anywhere on the line wins, and of the places closing it the nearest one. + /// The rest of the opening run closes a delimiter of up to half its length right behind it, and any later run on the same line closes a delimiter of up to its own length at its start. + /// The line is only scanned as far as a later run as long as the opening one, which closes all of it. + /// + private static func emphasis(of delimiter: Unicode.Scalar, in scalars: [Unicode.Scalar], at index: Int) -> (length: Int, closing: Int)? { + let opening = endOfRun(of: delimiter, in: scalars, from: index) - index + var laterRuns: [(start: Int, length: Int)] = [] + var position = index + opening + + while position < scalars.count, scalars[position] != "\n" { + guard scalars[position] == delimiter else { + position += 1 + continue + } + + let length = endOfRun(of: delimiter, in: scalars, from: position) - position + laterRuns.append((start: position, length: length)) + + if length >= opening { + break + } + + position += length + } + + let longestLater = min(laterRuns.map(\.length).max() ?? 0, opening) + let length = max(longestLater, opening / 2) + + guard length > 0 else { + return nil + } + + if 2 * length <= opening { + return (length: length, closing: index + length) + } + + guard let closing = laterRuns.first(where: { $0.length >= length }) else { + return nil + } + + return (length: length, closing: closing.start) + } +} diff --git a/Sources/Rainmaker/NoteContent/NoteNameSanitizer.swift b/Sources/Rainmaker/NoteContent/NoteNameSanitizer.swift new file mode 100644 index 0000000..755c904 --- /dev/null +++ b/Sources/Rainmaker/NoteContent/NoteNameSanitizer.swift @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +/// +/// The rules by which the notes app turns a title or a component of a category into a name it can give a file or a folder. +/// +/// ``NoteTitle`` and ``NoteCategory`` are built on it, which is why it works on Unicode scalars rather than on characters: the notes app counts and matches code points, so a decomposed letter followed by its combining mark counts as two of them, as it does here. +/// The character classes below are those the notes app's pattern matching uses, which are not the ones of Foundation's character sets. +/// +enum NoteNameSanitizer { + /// + /// The characters the notes app removes from every title and from every component of a category, because some operating systems do not allow them in file names, and because `/` and `\` would otherwise place a file elsewhere. + /// + static let removedCharacters: Set = ["*", "|", "/", "\\", ":", "\"", "<", ">", "?"] + + /// + /// The characters the notes app trims from both ends of a name, which are only these few ASCII characters and not every kind of white space ``isWhitespace(_:)`` recognizes. + /// + static let trimmedCharacters: Set = [" ", "\t", "\n", "\r", "\0", "\u{0B}"] + + /// + /// Whether the notes app's pattern matching considers the scalar white space, which are the ASCII tab, line feed, vertical tab, form feed, carriage return and space, the next line control, the Unicode line and paragraph separators and the horizontal spaces of Unicode such as the no-break space, the ideographic space and the Mongolian vowel separator. + /// + /// This is a fixed list rather than a Unicode property, because the list the notes app's pattern matching uses differs from every property: it includes the next line control, which Unicode classifies as a control character rather than a separator, and the Mongolian vowel separator, which Unicode no longer considers a space, but not the zero width space. + /// + static func isWhitespace(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 0x09 ... 0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x180E, 0x2000 ... 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000: + true + default: + false + } + } + + /// + /// Whether the scalar ends a line where the notes app takes the first line of a title, which besides line feed and carriage return are the vertical tab, the form feed, the next line control and the Unicode line and paragraph separators. + /// + static func isLineBreak(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 0x0A, 0x0B, 0x0C, 0x0D, 0x85, 0x2028, 0x2029: + true + default: + false + } + } + + /// + /// The name with ``removedCharacters`` removed, with any dots and white space at the start of each of its lines removed so that the file is not hidden, and with ``trimmedCharacters`` trimmed from both ends. + /// + /// Only a line feed starts a new line here, and the white space removed at the start of a line includes line feeds, so blank lines at the start of the name disappear along with the indentation of the first line which is not blank. + /// + static func sanitize(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + let kept = scalars.filter { removedCharacters.contains($0) == false } + var visible: [Unicode.Scalar] = [] + visible.reserveCapacity(kept.count) + var index = 0 + + while index < kept.count { + if index == 0 || kept[index - 1] == "\n" { + var end = index + + while end < kept.count, kept[end] == "." || isWhitespace(kept[end]) { + end += 1 + } + + if end > index { + index = end + continue + } + } + + visible.append(kept[index]) + index += 1 + } + + return trimmed(visible) + } + + /// + /// The scalars with ``trimmedCharacters`` removed from both ends. + /// + static func trimmed(_ scalars: [Unicode.Scalar]) -> [Unicode.Scalar] { + guard let first = scalars.firstIndex(where: { trimmedCharacters.contains($0) == false }) else { + return [] + } + + guard let last = scalars.lastIndex(where: { trimmedCharacters.contains($0) == false }) else { + return [] + } + + return Array(scalars[first ... last]) + } +} diff --git a/Sources/Rainmaker/NoteContent/NoteTitle.swift b/Sources/Rainmaker/NoteContent/NoteTitle.swift new file mode 100644 index 0000000..4a6dab2 --- /dev/null +++ b/Sources/Rainmaker/NoteContent/NoteTitle.swift @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +/// +/// The rules by which the notes app turns a note's content or a typed title into the ``Note/title`` it gives the note, for a client which wants to know that title before or without asking the server. +/// +/// The title of a note is the name of its file without the extension, so the notes app sanitizes every title it is given, and the notes API of version 1 never derives one from the content: a client which wants the title the web interface would show for some text, for example a Shortcuts action creating a note from text, derives it through ``derive(fromContent:)`` and passes it to ``Server/createNote(title:category:content:modification:isFavorite:)`` or ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``. +/// Both functions are pure, so they need neither a ``Server`` nor a connection, and they can run on any thread. +/// +/// They predict the server's result, but they do not replace it, and a caller still adopts the ``Note/title`` the server returns: +/// +/// - The server sanitizes the title it is given once more and returns its localized default title, such as "New note", where these return an empty string. +/// - When the category already holds a note of the title, the server appends a number such as `" (2)"`, shortening the title to keep it within 100 characters. +/// - A server whose database cannot store characters outside the Basic Multilingual Plane, such as most emoji, removes those as well, which these do not, because whether it does is not visible to a client. +/// - The server may store the name in another Unicode normalization form, which `String` comparison treats as equal anyway. +/// +public enum NoteTitle { + /// + /// The number of Unicode scalars the notes app keeps of a title, which counts a decomposed letter and its combining mark as two. + /// + static let maximumLength = 100 + + /// + /// Derive the title the notes app's web interface gives a note from the note's content. + /// + /// The Markdown syntax of the content is stripped first: list markers, the `#` of headings, lines underlining a heading with `=` or `-`, and the `*` and `_` delimiting emphasis, even within words. + /// The result is then sanitized as ``sanitize(_:)`` describes, which keeps the first line which is not blank, and sanitized once more, as the server does when it uses the title, which removes white space left at the end by cutting the title off. + /// + /// - Parameter content: The text of the note, as ``Note/content`` holds it. + /// + /// - Returns: The title, or an empty string when nothing remains of the content, in which case the server uses its localized default title such as "New note" instead. + /// + public static func derive(fromContent content: String) -> String { + let stripped = NoteMarkdownStripper.strip(Array(content.unicodeScalars)) + let firstPass = sanitize(String(String.UnicodeScalarView(stripped))) + + guard firstPass.isEmpty == false else { + return "" + } + + return sanitize(firstPass) + } + + /// + /// Sanitize a title, such as one a user typed, as the notes app does before it names a note's file after it. + /// + /// - The characters `*`, `|`, `/`, `\`, `:`, `"`, `<`, `>` and `?` are removed, because some operating systems do not allow them in file names. + /// - Dots and white space at the start are removed, so that the file is not hidden, and blank lines at the start disappear along with them. + /// - Only the first line is kept, where a line feed, a carriage return, a vertical tab, a form feed, the next line control and the Unicode line and paragraph separators all end a line. + /// - Every remaining white space character, such as a tab or a no-break space, becomes a plain space, while consecutive spaces are kept as they are. + /// - The title is cut off after 100 Unicode scalars, which counts a decomposed letter and its combining mark as two and an emoji made of several scalars as several. + /// + /// Markdown syntax is kept, so that a title such as `# Shopping list` stays as it is, while ``derive(fromContent:)`` would turn the same text into `Shopping list`. + /// + /// - Parameter title: The title to sanitize. + /// + /// - Returns: The sanitized title, or an empty string when nothing remains of it or it is `"0"`, which the notes app treats as empty, in both of which cases the server uses its localized default title such as "New note" instead. + /// + public static func sanitize(_ title: String) -> String { + let sanitized = NoteNameSanitizer.sanitize(Array(title.unicodeScalars)) + let firstLine = sanitized.prefix { NoteNameSanitizer.isLineBreak($0) == false } + let trimmed = NoteNameSanitizer.trimmed(Array(firstLine)) + let spaced = trimmed.map { NoteNameSanitizer.isWhitespace($0) ? " " : $0 } + let result = String(String.UnicodeScalarView(spaced.prefix(maximumLength))) + + guard result != "0" else { + return "" + } + + return result + } +} diff --git a/Sources/Rainmaker/Push/PushTarget.swift b/Sources/Rainmaker/Push/PushTarget.swift new file mode 100644 index 0000000..454deec --- /dev/null +++ b/Sources/Rainmaker/Push/PushTarget.swift @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The resolved WebSocket endpoint and the subset of requested subjects the server actually pushes. +/// +/// ``ServerEventCoordinator/pushTarget(from:requested:accountAddress:)`` derives it from the ``PushNotifications`` capability, and the coordinator opens a ``PushNotificationsConnection`` to it while polling every requested ``ServerSubject`` it does not cover. +/// +struct PushTarget { + /// + /// The `wss://` (or accepted `ws://`) endpoint to connect to, as checked by ``ServerEventCoordinator/isAcceptable(endpoint:accountAddress:)``. + /// + let endpoint: URL + + /// + /// The requested subjects the server advertises over `notify_push`. + /// + let subjects: Set +} diff --git a/Sources/Rainmaker/Push/ServerEventCoordinator.swift b/Sources/Rainmaker/Push/ServerEventCoordinator.swift index 5b2ad8b..2606896 100644 --- a/Sources/Rainmaker/Push/ServerEventCoordinator.swift +++ b/Sources/Rainmaker/Push/ServerEventCoordinator.swift @@ -5,9 +5,11 @@ import Foundation import os /// -/// Drives a single ``Server/events(_:)`` subscription: it discovers whether the server offers `notify_push`, prefers the WebSocket when it does, and otherwise polls, transparently switching and reconnecting so a consumer sees one uninterrupted stream of ``ServerEvent`` values. +/// Drives a single ``Server/events(_:)`` subscription: with the ``ServerEventTransport/automatic`` transport it discovers whether the server offers `notify_push`, prefers the WebSocket when it does, and otherwise polls, transparently switching and reconnecting so a consumer sees one uninterrupted stream of ``ServerEvent`` values. /// /// Every event is a re-fetch hint, so the polling fallback simply synthesizes the same hints on a timer that the WebSocket would deliver on change. This is why the two transports are interchangeable from the consumer's point of view. +/// With the ``ServerEventTransport/polling`` transport, and on platforms where ``platformSupportsWebSocket`` is `false`, it only polls and never asks for the capabilities or opens a ``PushNotificationsConnection``. +/// Each WebSocket session is reduced to a ``SessionOutcome``, which decides between reconnecting, retrying the authentication and polling for ``rediscoverInterval`` before looking at the capabilities again. /// struct ServerEventCoordinator { /// @@ -32,6 +34,14 @@ struct ServerEventCoordinator { /// var maximumAuthenticationAttempts = 3 + /// + /// How many consecutive WebSocket connections may end before they authenticated, reported as ``SessionOutcome/disconnected(wasAuthenticated:)`` with `false`, before polling for ``rediscoverInterval`` and then looking at the capabilities again. + /// + /// Without this limit an advertised endpoint the client cannot reach, for example behind a proxy which does not forward WebSocket upgrades, would be retried forever with a growing backoff and no events in between. A connection which authenticated resets the count, and so do an authentication rejection, which proves that the socket reached the server, and falling back to polling for any reason, so the count only covers consecutive failures to connect. + /// This is configurable so tests can exercise the fallback quickly. + /// + var maximumConnectionFailures = 3 + /// /// How long to wait after a WebSocket authentication rejection before retrying the socket. /// @@ -64,6 +74,19 @@ struct ServerEventCoordinator { /// var pongTimeout: TimeInterval = 10 + /// + /// Whether the platform lets an app rely on a WebSocket connection, which decides whether the ``ServerEventTransport/automatic`` transport considers `notify_push` at all. + /// + /// It is `false` on watchOS, where the system only grants such connections in narrow circumstances, such as an active audio streaming session, as Apple's technote TN3135 on low-level networking on watchOS describes, and `true` everywhere else. + /// + static var platformSupportsWebSocket: Bool { + #if os(watchOS) + false + #else + true + #endif + } + /// /// Run the subscription until the consumer stops it or an unrecoverable authentication failure occurs. /// @@ -80,8 +103,20 @@ struct ServerEventCoordinator { continuation.yield(.connected) } + guard options.transport == .automatic, Self.platformSupportsWebSocket else { + logger.debug("Polling only, without notify_push") + + while Task.isCancelled == false { + await pollWindow(subjects: options.subjects, interval: options.pollInterval, window: rediscoverInterval, into: continuation) + } + + continuation.finish() + return + } + var backoffSeconds = initialBackoff var authenticationAttempts = 0 + var connectionFailures = 0 while Task.isCancelled == false { let capabilities: CapabilitySet? @@ -103,6 +138,8 @@ struct ServerEventCoordinator { guard let target else { logger.debug("notify_push unavailable; polling") + connectionFailures = 0 + backoffSeconds = initialBackoff await pollWindow(subjects: options.subjects, interval: options.pollInterval, window: rediscoverInterval, into: continuation) continue } @@ -113,11 +150,14 @@ struct ServerEventCoordinator { switch outcome { case .authenticationRejected: + // The socket reached the server, so this ends a run of connections which failed to connect. + connectionFailures = 0 authenticationAttempts += 1 logger.notice("notify_push authentication rejected (attempt \(authenticationAttempts) of \(maximumAuthenticationAttempts))") if authenticationAttempts >= maximumAuthenticationAttempts { authenticationAttempts = 0 + backoffSeconds = initialBackoff await pollWindow(subjects: options.subjects, interval: options.pollInterval, window: rediscoverInterval, into: continuation) } else { try? await Task.sleep(nanoseconds: UInt64(authenticationRetryInterval * 1_000_000_000)) @@ -128,6 +168,17 @@ struct ServerEventCoordinator { if wasAuthenticated { backoffSeconds = initialBackoff + connectionFailures = 0 + } else { + connectionFailures += 1 + } + + if connectionFailures >= maximumConnectionFailures { + logger.notice("notify_push connection failed \(connectionFailures) times in a row; polling until retry") + connectionFailures = 0 + backoffSeconds = initialBackoff + await pollWindow(subjects: options.subjects, interval: options.pollInterval, window: rediscoverInterval, into: continuation) + continue } let jittered = backoffSeconds * Double.random(in: 0.8 ... 1.2) @@ -216,8 +267,11 @@ struct ServerEventCoordinator { /// /// Emit the given subjects' hints at the given interval for at most the given window, then return so the caller can re-discover capabilities. /// + /// With nothing to poll, because there are no subjects or the interval is not positive, it waits out the window instead, so that a caller looping over it does not spin. + /// private func pollWindow(subjects: Set, interval: TimeInterval, window: TimeInterval, into continuation: AsyncThrowingStream.Continuation) async { guard interval > 0, subjects.isEmpty == false else { + try? await Task.sleep(nanoseconds: UInt64(max(window, 0) * 1_000_000_000)) return } @@ -290,35 +344,3 @@ struct ServerEventCoordinator { return false } } - -// MARK: - Helpers - -/// -/// The outcome of one WebSocket session, telling ``ServerEventCoordinator`` how to proceed. -/// -enum SessionOutcome { - /// - /// The server rejected authentication, so the socket should be retried a bounded number of times before falling back to polling. - /// - case authenticationRejected - - /// - /// The socket dropped. `wasAuthenticated` is `true` when it had connected successfully first, which resets the reconnection backoff. - /// - case disconnected(wasAuthenticated: Bool) -} - -/// -/// The resolved WebSocket endpoint and the subset of requested subjects the server actually pushes. -/// -struct PushTarget { - /// - /// The `wss://` (or accepted `ws://`) endpoint to connect to. - /// - let endpoint: URL - - /// - /// The requested subjects the server advertises over `notify_push`. - /// - let subjects: Set -} diff --git a/Sources/Rainmaker/Push/SessionOutcome.swift b/Sources/Rainmaker/Push/SessionOutcome.swift new file mode 100644 index 0000000..127f6de --- /dev/null +++ b/Sources/Rainmaker/Push/SessionOutcome.swift @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +/// +/// The outcome of one WebSocket session, telling ``ServerEventCoordinator`` how to proceed. +/// +/// It is what the session the coordinator runs over a ``PushNotificationsConnection`` reduces that connection's ``PushNotificationsConnection/Failure`` to, so that the coordinator only counts what its retry and fallback decisions depend on. +/// +enum SessionOutcome { + /// + /// The server rejected authentication, so the socket should be retried a bounded number of times (``ServerEventCoordinator/maximumAuthenticationAttempts``) before falling back to polling. + /// + case authenticationRejected + + /// + /// The socket dropped. + /// + /// `wasAuthenticated` is `true` when it had connected successfully first, which resets the reconnection backoff and the count of consecutive connection failures. When it is `false` the socket never got as far as authenticating, which ``ServerEventCoordinator`` counts towards ``ServerEventCoordinator/maximumConnectionFailures`` before falling back to polling. + /// + case disconnected(wasAuthenticated: Bool) +} diff --git a/Sources/Rainmaker/RainmakerError.swift b/Sources/Rainmaker/RainmakerError.swift index f87e0a2..dc3e464 100644 --- a/Sources/Rainmaker/RainmakerError.swift +++ b/Sources/Rainmaker/RainmakerError.swift @@ -7,6 +7,15 @@ import Foundation /// Semantic errors specific to this library. /// public enum RainmakerError: Error, Equatable, CustomStringConvertible { + /// + /// A server app the intended action depends on is not available on the server, for example because it is not installed or disabled. + /// + /// Carries the identifier of the app, e.g. `"notes"` for the features built on ``Notes``. + /// The notes features report this when an endpoint of the notes app answers with a not found status the app itself did not send, which is what the server does for the routes of an app it does not serve. It is kept apart from ``notFound``, which those features reserve for a note or an attachment that does not exist, so that a client keeping its own copy of notes never takes a missing app for notes which were deleted. The exception is the retrieval of an attachment through ``Server/attachment(at:ofNote:)`` and ``Server/downloadAttachment(at:ofNote:to:force:)``, which reports an absent app as ``notFound`` as well, because the notes app answers every failure of that request with the same bare not found status; the ``Notes`` capability tells the cases apart. + /// A server whose `index.php` routing is broken or whose reverse proxy swallows the route answers the same way, so those causes cannot be told apart from the response alone. + /// + case appUnavailable(app: String) + /// /// The intended action requires credentials like user name and password but they were not given. /// @@ -32,11 +41,50 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible { /// case fileAlreadyExists(URL) + /// + /// The server does not have enough storage left for the account to save what was sent. + /// + /// The notes features report this when the notes app answers with the status `507` itself, for example when a note or an attachment would exceed the account's quota. + /// + case insufficientStorage + + /// + /// The server could not perform the intended action because the file it concerns is locked, for example while another client is writing it. + /// + /// The notes features report this when the notes app answers with the status `423` itself, which it only does after it has already retried for several seconds, so a client should back off rather than retry right away. + /// + case locked + + /// + /// The server does not support the HTTP method of the request on the requested endpoint. + /// + /// The notes features report this for an endpoint which an older release of the notes app does not offer for that method, which the server answers with the status `405` before the app is even involved. + /// + case methodNotAllowed + + /// + /// A note was not changed because it changed on the server since the entity tag the change was based on. + /// + /// ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` reports this when it was given an entity tag which no longer matches the note, in which case nothing was changed. + /// Carries the note as it currently is on the server, which the notes app sends along with the status `412`, so a client can resolve the conflict without a further request. + /// Its ``Note/entityTag`` is what a retried change has to be based on. + /// + case noteConflict(current: Note) + /// /// Whatever you were looking for is not there. /// + /// The notes features report this when the notes app itself answers that a note or an attachment does not exist, and ``appUnavailable(app:)`` when the app is not there at all, except for ``Server/attachment(at:ofNote:)`` and ``Server/downloadAttachment(at:ofNote:to:force:)``, which report every failure including an absent app as this case, see there. + /// case notFound + /// + /// The intended change was refused because the subject is read-only for the authenticated user. + /// + /// The notes features report this when the notes app answers with the status `403` itself, for example for a note shared without write access, see ``Note/isReadOnly``. + /// + case readOnly + /// /// The response most likely was not in the expected format or structure. /// @@ -69,6 +117,8 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible { /// public var description: String { switch self { + case let .appUnavailable(app: app): + "The \"\(app)\" app is not available on the server." case .credentialsRequired: "Credentials required" case let .destinationExists(url): @@ -79,8 +129,18 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible { "The item at \(url.compatibilityPath()) could not be enumerated: \(error)" case let .fileAlreadyExists(url): "A file already exists at: \(url.compatibilityPath())" + case .insufficientStorage: + "There is not enough storage left on the server." + case .locked: + "The subject is locked on the server." + case .methodNotAllowed: + "The server does not allow this method on the endpoint." + case let .noteConflict(current: note): + "The note \(note.id) changed on the server in the meantime." case .notFound: "Not found." + case .readOnly: + "The subject is read-only." case let .responseDecodingFailed(reason: reason): reason case let .sourceChanged(url): diff --git a/Sources/Rainmaker/Requests/Models/NoteCreationRequest.swift b/Sources/Rainmaker/Requests/Models/NoteCreationRequest.swift new file mode 100644 index 0000000..5cb1fe9 --- /dev/null +++ b/Sources/Rainmaker/Requests/Models/NoteCreationRequest.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON body ``Server/createNote(title:category:content:modification:isFavorite:)`` sends to `POST /index.php/apps/notes/api/v1/notes`. +/// +/// This is a data transfer object shaped as the notes app expects it, which is why its properties carry the server's names rather than those of ``Note``. It is the request counterpart of the data transfer objects in `Responses/Models` and is not exposed outside this module. +/// +/// The synthesized encoding leaves out ``modified`` when it is `nil`, which the server takes as a request to keep the moment it created the note's file at. +/// +struct NoteCreationRequest: Encodable { + /// + /// The requested title, which the server sanitizes before it becomes the file name and therefore ``Note/title``. + /// + let title: String + + /// + /// The requested category, which the server sanitizes before it becomes the folder the note is filed in and therefore ``Note/category``. + /// + let category: String + + /// + /// The text of the new note, which becomes ``Note/content``. + /// + let content: String + + /// + /// The modification moment to stamp onto the new note's file, in whole seconds since the Unix epoch as `Date.wholeSecondsSince1970` converts it, which becomes ``Note/modification``. + /// + let modified: Int64? + + /// + /// Whether the new note is marked as a favorite, which becomes ``Note/isFavorite``. + /// + let favorite: Bool +} diff --git a/Sources/Rainmaker/Requests/Models/NoteUpdateRequest.swift b/Sources/Rainmaker/Requests/Models/NoteUpdateRequest.swift new file mode 100644 index 0000000..bca12ab --- /dev/null +++ b/Sources/Rainmaker/Requests/Models/NoteUpdateRequest.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON body ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` sends to `PUT /index.php/apps/notes/api/v1/notes/`. +/// +/// This is a data transfer object shaped as the notes app expects it, which is why its properties carry the server's names rather than those of ``Note``. It is the request counterpart of the data transfer objects in `Responses/Models` and is not exposed outside this module. +/// +/// Every property is optional, and the synthesized encoding leaves out those which are `nil`, because the server only changes what the body names and leaves everything else of the note as it is. +/// +struct NoteUpdateRequest: Encodable { + /// + /// The new title, which the server sanitizes before it renames the note's file, or `nil` to keep ``Note/title``. + /// + let title: String? + + /// + /// The new category, which the server sanitizes before it moves the note's file into the matching folder, or `nil` to keep ``Note/category``. + /// + let category: String? + + /// + /// The new text of the note, or `nil` to keep ``Note/content``. + /// + let content: String? + + /// + /// The modification moment to stamp onto the note's file, in whole seconds since the Unix epoch as `Date.wholeSecondsSince1970` converts it, or `nil` to leave ``Note/modification`` to the server. + /// + let modified: Int64? + + /// + /// Whether the note is to be marked as a favorite, or `nil` to keep ``Note/isFavorite``. + /// + let favorite: Bool? +} diff --git a/Sources/Rainmaker/Requests/Models/NotesSettingsUpdateRequest.swift b/Sources/Rainmaker/Requests/Models/NotesSettingsUpdateRequest.swift new file mode 100644 index 0000000..6c77565 --- /dev/null +++ b/Sources/Rainmaker/Requests/Models/NotesSettingsUpdateRequest.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON body ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` sends to `PUT /index.php/apps/notes/api/v1/settings`. +/// +/// This is a data transfer object shaped as the notes app expects it, which is why its properties carry the server's names rather than those of ``NotesSettings``. It is the request counterpart of the data transfer objects in `Responses/Models` and is not exposed outside this module. +/// +/// Every property is optional, and the synthesized encoding leaves out those which are `nil`, because the server keeps every setting the body does not name, while a setting sent as `null` is reset to its default. +/// +struct NotesSettingsUpdateRequest: Encodable { + /// + /// The new path of the folder the notes are stored in, relative to the account's files, or `nil` to keep ``NotesSettings/notesPath``. + /// + let notesPath: String? + + /// + /// The new file extension for notes created from now on, or `nil` to keep ``NotesSettings/fileSuffix``. + /// + let fileSuffix: String? + + /// + /// The raw value of the new ``NoteMode``, which is the string the server expects, or `nil` to keep ``NotesSettings/noteMode``. + /// + let noteMode: String? + + /// + /// Whether the web interface is to list hidden files and folders, or `nil` to keep ``NotesSettings/showsHiddenFiles``. + /// + let showHidden: Bool? + + /// + /// Whether the web interface is to open the most recent note when it starts, or `nil` to keep ``NotesSettings/loadsRecentNoteOnStartUp``. + /// + let loadRecentOnStartUp: Bool? +} diff --git a/Sources/Rainmaker/Requests/MultipartFormData.swift b/Sources/Rainmaker/Requests/MultipartFormData.swift new file mode 100644 index 0000000..b54ba36 --- /dev/null +++ b/Sources/Rainmaker/Requests/MultipartFormData.swift @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// A `multipart/form-data` body with a single file field, written to a local file so that it can be sent through an upload task rather than held in memory. +/// +/// This is what ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` and ``Server/addAttachment(_:toNote:fileName:)-(Data,Int,String)`` send, because the notes app only takes an attachment as the file of a form, as a browser would upload it. The body is staged in a file of its own and then sent by ``Requesting/upload(for:fromFile:delegate:)``, the same way ``Server/upload(_:to:force:chunkSize:)`` stages a chunk, so that neither the file nor its body has to fit into memory. +/// +/// The caller removes the staged body once it has been sent, also when writing it failed halfway. +/// +struct MultipartFormData { + /// + /// The line which delimits the parts of the body, unique per body so that it cannot occur within the file it carries. + /// + /// It is announced in ``contentType`` and therefore has to be sent along with the body. + /// + let boundary: String + + /// + /// The value of the `Content-Type` header a request carrying this body has to send, which announces ``boundary``. + /// + var contentType: String { + "multipart/form-data; boundary=\(boundary)" + } + + /// + /// Create a body with a boundary which differs from that of any other body. + /// + /// - Parameters: + /// - boundary: The line to delimit the parts of the body with. Defaults to one derived from a random UUID. + /// + init(boundary: String = "Rainmaker-\(UUID().uuidString)") { + self.boundary = boundary + } + + /// + /// Escape a name for a quoted parameter of the `Content-Disposition` header of a part, as browsers do. + /// + /// A double quote would end the quoted value early and a line break would end the header, so the former is replaced by `%22` and the latter are removed. Everything else, including characters beyond ASCII, is kept as UTF-8, which is what a server reading a form expects. The server does not decode `%22` again, so a file name with a double quote arrives with `%22` in its place. + /// + /// - Parameters: + /// - name: The name to escape. + /// + static func escapedParameterValue(_ name: String) -> String { + name + .replacingOccurrences(of: "\"", with: "%22") + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: "\n", with: "") + } + + /// + /// Write the body with the contents of a local file as its file field to the given location, copying through a buffer of the given size rather than reading the whole file into memory. + /// + /// The copy checks for cancellation of the calling task before every pass, so cancelling ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` while it stages a large file stops the copy with `CancellationError` rather than finishing it first. The caller removes what was written up to then. + /// + /// - Parameters: + /// - source: The local file whose contents become the file field. + /// - fieldName: The name of the form field, which the server looks the file up by. + /// - fileName: The name of the file to announce, escaped through ``escapedParameterValue(_:)``. + /// - destination: The local location to write the body to, which is replaced when it exists. + /// - bufferSize: The number of bytes to copy at once, usually ``Server/stagingBufferSize``. + /// + func writeFile(from source: URL, fieldName: String, fileName: String, to destination: URL, bufferSize: Int) throws { + let input = try FileHandle(forReadingFrom: source) + + defer { + try? input.close() + } + + try write(fieldName: fieldName, fileName: fileName, to: destination) { output in + while true { + try Task.checkCancellation() + + guard let buffer = try input.read(upToCount: bufferSize), buffer.isEmpty == false else { + break + } + + try output.write(contentsOf: buffer) + } + } + } + + /// + /// Write the body with the given bytes as its file field to the given location. + /// + /// This is the counterpart of ``writeFile(from:fieldName:fileName:to:bufferSize:)`` for bytes already in memory, which spares writing them to a file of their own first. + /// + /// - Parameters: + /// - data: The bytes which become the file field. + /// - fieldName: The name of the form field, which the server looks the file up by. + /// - fileName: The name of the file to announce, escaped through ``escapedParameterValue(_:)``. + /// - destination: The local location to write the body to, which is replaced when it exists. + /// + func write(_ data: Data, fieldName: String, fileName: String, to destination: URL) throws { + try write(fieldName: fieldName, fileName: fileName, to: destination) { output in + try output.write(contentsOf: data) + } + } + + /// + /// Write the headers of the file part, then whatever the given closure writes as its contents, then the closing boundary. + /// + /// - Parameters: + /// - fieldName: The name of the form field. + /// - fileName: The name of the file to announce. + /// - destination: The local location to write the body to. + /// - writeContents: Writes the contents of the file to the given handle. + /// + private func write(fieldName: String, fileName: String, to destination: URL, writeContents: (FileHandle) throws -> Void) throws { + try Data().write(to: destination) + let output = try FileHandle(forWritingTo: destination) + + defer { + try? output.close() + } + + // The part's own type does not matter to the server, which only reads the name and the contents of an uploaded file, so the generic type for bytes is announced rather than a guess. + let head = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(Self.escapedParameterValue(fieldName))\"; filename=\"\(Self.escapedParameterValue(fileName))\"\r\nContent-Type: application/octet-stream\r\n\r\n" + + try output.write(contentsOf: Data(head.utf8)) + try writeContents(output) + try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8)) + } +} diff --git a/Sources/Rainmaker/Responses/HTTPStatus.swift b/Sources/Rainmaker/Responses/HTTPStatus.swift index d9e9073..a40c49d 100644 --- a/Sources/Rainmaker/Responses/HTTPStatus.swift +++ b/Sources/Rainmaker/Responses/HTTPStatus.swift @@ -184,6 +184,11 @@ enum HTTPStatus: Int, RawRepresentable, CustomStringConvertible { /// case expectationFailed = 417 + /// + /// Equals the raw status code of `423`. + /// + case locked = 423 + /// /// Equals the raw status code of `500`. /// @@ -294,6 +299,8 @@ enum HTTPStatus: Int, RawRepresentable, CustomStringConvertible { "Requested range not satisfiable" case .expectationFailed: "Expectation failed" + case .locked: + "Locked" case .internalServerError: "Internal server error" case .notImplemented: diff --git a/Sources/Rainmaker/Responses/Models/AttachmentUploadResponse.swift b/Sources/Rainmaker/Responses/Models/AttachmentUploadResponse.swift new file mode 100644 index 0000000..ef9f89d --- /dev/null +++ b/Sources/Rainmaker/Responses/Models/AttachmentUploadResponse.swift @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// The JSON object the notes app answers an uploaded attachment with, for `POST /index.php/apps/notes/api/v1.4/attachment/`. +/// +/// This is a data transfer object which ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` and its counterpart for `Data` reduce to the path it carries right after decoding. +/// +struct AttachmentUploadResponse: Decodable { + /// + /// The path the server stored the attachment at, relative to the folder of the note's category, which is what ``Server/attachment(at:ofNote:)`` and ``Server/deleteAttachment(at:ofNote:)`` take. + /// + let filename: String +} diff --git a/Sources/Rainmaker/Responses/Models/CurrentUserResponse.swift b/Sources/Rainmaker/Responses/Models/CurrentUserResponse.swift new file mode 100644 index 0000000..d41a0a1 --- /dev/null +++ b/Sources/Rainmaker/Responses/Models/CurrentUserResponse.swift @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +/// +/// This is the JSON response as returned by the server for `GET /ocs/v2.php/cloud/user`. +/// +/// This is a data transfer object covering the OCS envelope and the two fields of the account details ``Server/currentUser()`` maps onto the public ``User``. +/// The server sends many more details of the account, such as its quota, its groups and when it last logged in, which are deliberately not decoded, so that a change to any of them cannot break the lookup. +/// +struct CurrentUserResponse: Decodable { + /// + /// The OCS envelope wrapping the status of the request and the account details. + /// + struct OCS: Decodable { + /// + /// The status of the OCS request, which ``Server/currentUser()`` checks before it reads ``OCS/data``. + /// + struct Meta: Decodable { + /// + /// The textual status, which is `"ok"` for a successful request. + /// + let status: String + + /// + /// The numeric OCS status code, which is reported in the error when ``status`` is not `"ok"`. + /// + let statuscode: Int + + /// + /// The optional message explaining a failure, which is reported in the error when ``status`` is not `"ok"`. + /// + let message: String? + } + + /// + /// The details of the account which ``Server/currentUser()`` needs. + /// + struct Data: Decodable { + /// + /// The identifier the server keys the account by, which becomes ``User/id``. + /// + let id: String + + /// + /// The display name of the account, which becomes ``User/displayName`` and which the server falls back to the identifier for. + /// + let displayname: String + } + + /// + /// The status of the request. + /// + let meta: Meta + + /// + /// The details of the account. + /// + let data: Data + } + + /// + /// The OCS envelope, which is the only top-level key of the response. + /// + let ocs: OCS +} diff --git a/Sources/Rainmaker/Responses/Models/NoteEntry.swift b/Sources/Rainmaker/Responses/Models/NoteEntry.swift index 279adc2..a95047b 100644 --- a/Sources/Rainmaker/Responses/Models/NoteEntry.swift +++ b/Sources/Rainmaker/Responses/Models/NoteEntry.swift @@ -8,17 +8,22 @@ import Foundation /// /// This is a data transfer object which exists because such a response mixes two shapes: a note modified since the requested moment arrives complete, while an older one is reduced to its identifier. Neither ``Server/notes(changedSince:)`` nor its ``NoteChanges`` result exposes this distinction as a type; it is partitioned away immediately after decoding. /// -enum NoteEntry: Decodable { +/// It is generic over the type a note sent in full is decoded as, which is the ``NoteChangeSet/Item`` of the listing being read: ``Note`` for the listings returning ``NoteChanges``, and ``NoteSummary`` for those returning ``NoteSummaryChanges``, whose requests ask the server to leave out the content of every note. +/// +enum NoteEntry: Decodable { /// - /// A note which changed and therefore arrived complete. + /// A note which changed and therefore arrived complete, or complete but for the content a summary listing asked the server to leave out. /// - case changed(Note) + case changed(Item) /// /// The identifier of a note which did not change and was therefore reduced to it. /// case unchanged(id: Int) + /// + /// The keys which tell the two shapes apart. + /// private enum CodingKeys: String, CodingKey { case id case title @@ -28,6 +33,7 @@ enum NoteEntry: Decodable { /// Decode one element by telling the two shapes apart. /// /// The presence of `title` is what discriminates them, rather than attempting to decode a whole ``Note`` and treating a failure as a reduced entry: `title` is a field the API has carried since its very first version, so a genuinely malformed note stays a decoding error instead of being silently reported as unchanged. + /// That is also why a summary listing only ever asks the server to leave out the content and never the title. /// init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -38,6 +44,6 @@ enum NoteEntry: Decodable { return } - self = try .changed(Note(from: decoder)) + self = try .changed(Item(from: decoder)) } } diff --git a/Sources/Rainmaker/Responses/ResponseParser.swift b/Sources/Rainmaker/Responses/ResponseParser.swift index aa0acea..be183f4 100644 --- a/Sources/Rainmaker/Responses/ResponseParser.swift +++ b/Sources/Rainmaker/Responses/ResponseParser.swift @@ -64,11 +64,7 @@ enum ResponseParser { throw RainmakerError.responseDecodingFailed(reason: "Failed to get last modified date for: \(href.absoluteString)") } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "E, dd MMM yyyy HH:mm:ss Z" - - guard let modification = formatter.date(from: lastModifiedString) else { + guard let modification = Date(httpDate: lastModifiedString) else { throw RainmakerError.responseDecodingFailed(reason: "Failed to parse last modified date: \(lastModifiedString)") } diff --git a/Sources/Rainmaker/Server+Events.swift b/Sources/Rainmaker/Server+Events.swift index 7c3cd3e..70e5b4e 100644 --- a/Sources/Rainmaker/Server+Events.swift +++ b/Sources/Rainmaker/Server+Events.swift @@ -7,14 +7,18 @@ public extension Server { /// /// Observe server-side changes, preferring the `notify_push` WebSocket when the server advertises it and falling back to polling otherwise. /// - /// The transport is chosen automatically from the server's ``PushNotifications`` capability and switched transparently on connection loss, so the returned stream is uninterrupted regardless of which transport is active. Every ``ServerEvent`` is a hint to re-fetch the relevant state (for example calling ``notifications()`` in response to ``ServerEvent/notifications``), never a payload, which is what makes the WebSocket and polling interchangeable to the consumer. + /// With the default ``ServerEventTransport/automatic`` transport of ``ServerEventOptions/transport``, the transport is chosen from the server's ``PushNotifications`` capability and switched transparently on connection loss, so the returned stream is uninterrupted regardless of which transport is active. Every ``ServerEvent`` is a hint to re-fetch the relevant state (for example calling ``notifications()`` in response to ``ServerEvent/notifications``), never a payload, which is what makes the WebSocket and polling interchangeable to the consumer. /// - /// Credentials are required: the WebSocket handshake and the polled endpoints are user-scoped. A stream created without credentials, or whose credentials are rejected by the server later, finishes by throwing ``RainmakerError/credentialsRequired`` (or ``RainmakerError/unexpectedStatus(code:)`` with `401`) so the client can prompt for re-authentication. Transient network failures are handled internally by reconnecting and are never surfaced. + /// The automatic transport falls back to polling for a while and then looks at the capabilities again in three cases: when the server does not advertise `notify_push` for any requested subject, when the WebSocket rejects the credentials three times in a row, and when the WebSocket fails to connect three times in a row without ever authenticating, for example because a proxy does not forward WebSocket upgrades. A connection which authenticated resets that count. On watchOS the automatic transport always polls, because the system only grants WebSocket connections in narrow circumstances. + /// + /// The ``ServerEventTransport/polling`` transport only polls at ``ServerEventOptions/pollInterval``: it never requests the capabilities and never opens a WebSocket, so the stream itself sends no requests at all. + /// + /// Credentials are required: the WebSocket handshake and the polled endpoints are user-scoped. A stream created without credentials finishes by throwing ``RainmakerError/credentialsRequired``. With the automatic transport on platforms other than watchOS, credentials the server rejects later end the stream by throwing ``RainmakerError/credentialsRequired`` or ``RainmakerError/unexpectedStatus(code:)`` with `401`, so the client can prompt for re-authentication, while polling alone leaves noticing them to the requests the consumer makes in response to the hints. Transient network failures are handled internally by reconnecting and are never surfaced. /// /// The stream ends when the consumer stops iterating it. /// /// - Parameters: - /// - options: Which subjects to observe and at which cadences. See ``ServerEventOptions``. + /// - options: Which subjects to observe, over which transport and at which cadences. See ``ServerEventOptions``. /// /// - Returns: A stream of ``ServerEvent`` hints. /// @@ -45,7 +49,7 @@ public extension Server { /// - subjects: The subjects to observe. Defaults to all of them. /// - pollInterval: The polling interval in seconds used when the WebSocket is unavailable. Defaults to 30. /// - /// - Returns: The stream of ``ServerEvent`` values, identical to calling ``events(_:)`` with a matching ``ServerEventOptions``. + /// - Returns: The stream of ``ServerEvent`` values, identical to calling ``events(_:)`` with a matching ``ServerEventOptions``, and therefore with the ``ServerEventTransport/automatic`` transport. /// func events(_ subjects: Set = Set(ServerSubject.allCases), pollInterval: TimeInterval = 30) -> AsyncThrowingStream { events(ServerEventOptions(subjects: subjects, pollInterval: pollInterval)) diff --git a/Sources/Rainmaker/Server+Notes.swift b/Sources/Rainmaker/Server+Notes.swift new file mode 100644 index 0000000..ee39e37 --- /dev/null +++ b/Sources/Rainmaker/Server+Notes.swift @@ -0,0 +1,1270 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation + +public extension Server { + /// + /// List all notes of the authenticated user. + /// + /// Notes are provided by the server's notes app which, unlike most of what this library covers, is not part of a Nextcloud installation and has to be installed separately. Whether it is available can be checked in advance via the ``Notes`` capability, e.g. `try await capabilities().contains(Notes.self)`. When the app is unavailable the underlying endpoint does not exist and this call throws ``RainmakerError/appUnavailable(app:)`` for `"notes"`. + /// + /// The very same error is what a server causes whose `index.php` routing is disabled or whose reverse proxy swallows the route, so those causes cannot be told apart from the response alone. It is deliberately not ``RainmakerError/notFound``, which the notes features reserve for a note or an attachment that does not exist, so that a client keeping its own copy never takes a missing app for an account without notes. The one exception is the retrieval of an attachment through ``attachment(at:ofNote:)`` and ``downloadAttachment(at:ofNote:to:force:)``, which reports every failure including an absent app as ``RainmakerError/notFound``, see there. + /// + /// An app which is installed but older than ``Notes/minimumAPIVersion`` is reported separately, as ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``. That requirement is checked on every response, because the notes API advertises the versions it serves in a header of its own, and it can be checked in advance through ``Notes/isSupported``. + /// + /// The whole collection is retrieved in a single request, because the endpoint returns everything at once unless a chunk size is requested, which this deliberately does not do. Use ``notes(changedSince:)`` to retrieve only what changed since an earlier call, ``noteChunks(changedSince:chunkSize:)`` to retrieve it in chunks of a bounded size, and ``noteSummaries(changedSince:)`` to leave out the text of every note. + /// + /// > Warning: Every note including its full content is fetched and held in memory at once, so what this costs grows with the size of the account's notes. + /// + /// A note the server could not read is listed like any other and does not fail the call. It carries ``Note/hasError`` and its ``Note/content`` is a message about the failure rather than the note's text, so anything which stores what it retrieves has to check that first. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Returns: The notes in the order returned by the server. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func notes() async throws -> [Note] { + try requireCredentials() + logger.debug("Fetching notes...") + + let request = try makeNotesAPIRequest(for: "notes", method: .get) + let (data, _) = try await notesAPIResponse(for: request) + + return try decodeNotesAPIPayload([Note].self, from: data, describing: "the notes") + } + + /// + /// List the notes of the authenticated user which changed since a given moment, together with the identifiers of those which did not. + /// + /// This is the incremental counterpart of ``notes()`` for a client keeping its own copy of the notes: the server returns every note it recorded a change for at or after `changedSince` in full, and reduces every note it did not to its identifier alone. Both together are the complete set of notes the account has, which is what makes deletions detectable. See ``NoteChanges`` for how the two halves are meant to be applied. + /// + /// The moment is sent to the server as its `pruneBefore` parameter, converted to whole seconds since the Unix epoch. A moment at or before the epoch, such as `Date.distantPast`, prunes nothing and therefore returns every note in full, which is how a first synchronization starts. + /// + /// Along with the notes, the result carries what the server says about the response in its headers: ``NoteChanges/lastModified`` is the moment to pass on the next call, and ``NoteChanges/entityTag`` is what ``notes(changedSince:ifChangedFrom:)`` takes to skip the transfer while nothing changed. The whole collection is retrieved in a single response, so the result is always ``NoteChanges/isComplete``. A client which must not hold every changed note at once retrieves them in chunks through ``notes(changedSince:chunkSize:continuingAfter:)`` or ``noteChunks(changedSince:chunkSize:)`` instead. + /// + /// > Warning: The server compares this moment against its own record of when it last noticed each note change, which is not the same as that note's ``Note/modification`` date. A note may be from 2020, but when the server only found it today it is not pruned from the response. Never pass a note's ``Note/modification`` back in as this moment; pass the ``NoteChanges/lastModified`` of the previous call, which is the server's own time at which it started answering, and which the API defines as the value to reuse. + /// + /// Everything else, including how an unavailable app surfaces and how a note the server could not read is reported, matches ``notes()``. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteChanges/lastModified`` of the previous call, and measured against the server's own record of when it last saw a note change rather than against ``Note/modification``. + /// + /// - Returns: The changed notes, the identifiers of the unchanged ones and what the response headers say about them. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func notes(changedSince: Date) async throws -> NoteChanges { + try requireCredentials() + logger.debug("Fetching notes changed since \(changedSince)...") + + return try await retrieveNoteChanges(changedSince: changedSince) + } + + /// + /// List the notes of the authenticated user which changed since a given moment like ``notes(changedSince:)``, unless the answer would be the same as the one a given entity tag was taken from. + /// + /// The request carries the entity tag in its `If-None-Match` header. When the server would answer exactly what it answered when it handed out that tag, it responds with an empty `304 Not Modified` instead, which this returns as `nil`. That spares a frequently polling client the transfer of the identifiers of every note while nothing changed. + /// + /// Pass the ``NoteChanges/entityTag`` and the ``NoteChanges/lastModified`` of the previous result. The server computes the tag from the body it would send rather than from the moment, so the tag matches whenever the body would be the same, which is the case while no note changed, none was added and none was deleted. The first call after a result carrying notes in full still receives a complete response, because it now reduces those notes to their identifiers, while every further call answers `nil` until something changes. A tag which does not match simply results in a complete response as ``notes(changedSince:)`` would return it. A tag with or without quotes and with a `W/` prefix is accepted alike. + /// + /// `nil` deliberately differs from an empty ``NoteChanges``, which would claim that the account has no notes at all and make a client keeping its own copy delete them. On `nil`, keep the previous `changedSince` and entity tag for the next call. That never skips a change, because the server keeps every note which changed in the very second the moment names. + /// + /// The same requirement and the same failure modes as ``notes(changedSince:)`` apply. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteChanges/lastModified`` of the previous result. + /// - entityTag: The ``NoteChanges/entityTag`` of the previous result. + /// + /// - Returns: The changed notes, the identifiers of the unchanged ones and what the response headers say about them, or `nil` when the server answered that nothing changed. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func notes(changedSince: Date, ifChangedFrom entityTag: String) async throws -> NoteChanges? { + try requireCredentials() + logger.debug("Fetching notes changed since \(changedSince) unless unchanged...") + + return try await retrieveNoteChanges(changedSince: changedSince, ifChangedFrom: entityTag) + } + + /// + /// Retrieve one chunk of the notes of the authenticated user which changed since a given moment, either the first one of a pass or the one following a given cursor. + /// + /// This is the chunked counterpart of ``notes(changedSince:)`` for a client which must not hold every changed note at once, such as an extension or a watch app with little memory to spare. The server orders the notes it would send in full by when it last saw each of them change and sends at most `chunkSize` of them per response. Every chunk but the last carries a ``NoteChanges/chunkCursor`` to pass as `cursor` for the next one, along with the ``NoteChanges/pendingCount`` of notes still to come, and lists no identifiers in ``NoteChanges/unchanged``. + /// + /// The last chunk, which ``NoteChanges/isComplete``, is the only one which lists the identifiers of the other notes the account has, and it lists those the earlier chunks sent in full as well. Deletions must therefore only be derived from the last chunk, from its ``NoteChanges/changed`` and ``NoteChanges/unchanged`` alone, and never from what a pass collected along the way. + /// + /// Every chunk of one pass carries the same ``NoteChanges/lastModified``, because the cursor keeps the moment the server started answering the first chunk. Remember it only once the last chunk was applied, so that an interrupted pass is repeated rather than skipped. + /// + /// A cursor is a plain value meant to be handed back as it is. That is what lets a pass which was interrupted, for example because the system suspended the app, continue later from the cursor of the last chunk applied rather than start over, provided `changedSince` is the same as for the chunks before. A cursor the server cannot read makes it start a new pass with the first chunk instead. + /// + /// ``noteChunks(changedSince:chunkSize:)`` performs a whole pass with this call, as a stream of chunks. + /// + /// The server also offers to narrow a listing down to one category, which this deliberately never asks for: that filter narrows the identifiers of the last chunk as well, which would make every note outside the category look deleted. + /// + /// Everything else, including how `changedSince` is measured, how an unavailable app surfaces and how a note the server could not read is reported, matches ``notes(changedSince:)``. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteChanges/lastModified`` of the previous pass, and the same for every chunk of one pass. + /// - chunkSize: The number of notes to send in full at most, which is raised to one when smaller, because the server takes zero as a request not to split the response at all. + /// - cursor: The ``NoteChanges/chunkCursor`` of the previous chunk of the same pass, or `nil` to retrieve the first chunk of a new pass. Defaults to `nil`. + /// + /// - Returns: The notes of this chunk, the identifiers of the unchanged notes when it is the last chunk, and what the response headers say about them. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func notes(changedSince: Date, chunkSize: Int, continuingAfter cursor: String? = nil) async throws -> NoteChanges { + try requireCredentials() + logger.debug("Fetching a chunk of notes changed since \(changedSince)...") + + return try await retrieveNoteChanges(changedSince: changedSince, chunkSize: chunkSize, continuingAfter: cursor) + } + + /// + /// Retrieve the first chunk of the notes of the authenticated user which changed since a given moment like ``notes(changedSince:chunkSize:continuingAfter:)``, unless the answer would be the same as the one a given entity tag was taken from. + /// + /// This is to a chunked pass what ``notes(changedSince:ifChangedFrom:)`` is to a single response: the request carries the entity tag in its `If-None-Match` header, and the server answers with an empty `304 Not Modified` instead of the chunk when it would send exactly what it sent when it handed out that tag, which this returns as `nil`. + /// + /// Pass the ``NoteChanges/entityTag`` and the ``NoteChanges/lastModified`` of the last chunk of the previous pass. While no note changed, the first chunk sends no note in full and therefore already is the last one, listing nothing but identifiers, so its tag does not depend on the chunk size and matches that of a response to ``notes(changedSince:)`` as well. As with the single response, the first call after a pass which sent notes in full still receives a complete chunk, while every further call answers `nil` until something changes. When a chunk arrives which is not the last one, continue the pass with ``notes(changedSince:chunkSize:continuingAfter:)``. + /// + /// There is no conditional variant for the chunks after the first, because each of them is only requested when the first one announced that changes are pending. + /// + /// `nil` deliberately differs from an empty ``NoteChanges`` for the reasons ``notes(changedSince:ifChangedFrom:)`` gives. On `nil`, keep the previous `changedSince` and entity tag for the next call. + /// + /// The same requirement and the same failure modes as ``notes(changedSince:chunkSize:continuingAfter:)`` apply. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteChanges/lastModified`` of the previous pass. + /// - chunkSize: The number of notes to send in full at most, which is raised to one when smaller. + /// - entityTag: The ``NoteChanges/entityTag`` of the last chunk of the previous pass. + /// + /// - Returns: The first chunk of a new pass, or `nil` when the server answered that nothing changed. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func notes(changedSince: Date, chunkSize: Int, ifChangedFrom entityTag: String) async throws -> NoteChanges? { + try requireCredentials() + logger.debug("Fetching the first chunk of notes changed since \(changedSince) unless unchanged...") + + return try await retrieveNoteChanges(changedSince: changedSince, chunkSize: chunkSize, ifChangedFrom: entityTag) + } + + /// + /// Retrieve every chunk of one pass over the notes of the authenticated user which changed since a given moment, in order, as a stream. + /// + /// Each element is the result of one call to ``notes(changedSince:chunkSize:continuingAfter:)``, the first without a cursor and every further one with the ``NoteChanges/chunkCursor`` of the element before. The stream finishes after the element which ``NoteChanges/isComplete``, which is the only one deletions may be derived from and after which ``NoteChanges/lastModified`` is worth remembering, see there. + /// + /// A chunk is only requested when the consumer asks for the next element, so the stream never holds more than the chunk being retrieved. A consumer which applies each chunk before it continues therefore needs no more memory for a pass than for one chunk, which is the point of retrieving notes in chunks. Merging the chunks is deliberately left to the consumer, because a merged result would hold every note at once and would no longer tell which identifiers came with the last chunk. + /// + /// When the consumer stops iterating, no further chunk is requested. Cancelling the task which iterates cancels the request in flight, which finishes the stream by throwing the error the cancelled request throws, such as `URLError.cancelled`, while a cancellation between two chunks ends the stream without requesting another one. Either way the last chunk received is not complete, which is why a consumer checks ``NoteChanges/isComplete`` rather than assume that a stream which ended was a whole pass. + /// + /// The stream finishes by throwing the first error a request throws, with the same failure modes as ``notes(changedSince:chunkSize:continuingAfter:)``, including ``RainmakerError/credentialsRequired`` before anything is sent when no credentials are set. The chunks received until then remain valid, and the cursor of the last one continues the pass through ``notes(changedSince:chunkSize:continuingAfter:)``. A server which answers a chunk with the very cursor it was asked to continue from would repeat that chunk forever, so the stream finishes by throwing ``RainmakerError/responseDecodingFailed(reason:)`` instead. + /// + /// ```swift + /// var lastChunk: NoteChanges? + /// + /// for try await chunk in server.noteChunks(changedSince: store.lastModified ?? .distantPast, chunkSize: 50) { + /// // Each chunk is applied as it arrives, so it can be released before the next one is requested. + /// store.upsert(chunk.changed.filter { $0.hasError == false }) + /// lastChunk = chunk + /// } + /// + /// // Only the last chunk lists every note the account has, so deletions are derived from it alone. + /// guard let lastChunk, lastChunk.isComplete else { + /// return + /// } + /// + /// store.deleteAll(exceptFor: lastChunk.changed.map(\.id) + lastChunk.unchanged) + /// store.lastModified = lastChunk.lastModified + /// store.entityTag = lastChunk.entityTag + /// ``` + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteChanges/lastModified`` of the previous pass. + /// - chunkSize: The number of notes to send in full at most per chunk, which is raised to one when smaller. + /// + /// - Returns: A stream of the chunks of one pass, the last of which is complete. + /// + func noteChunks(changedSince: Date, chunkSize: Int) -> AsyncThrowingStream { + logger.debug("Starting a chunked pass over notes changed since \(changedSince)...") + + let pass = NoteChunkPass { cursor in + try await self.notes(changedSince: changedSince, chunkSize: chunkSize, continuingAfter: cursor) + } + + // Unfolding rather than producing from a task of its own makes each request wait until the consumer asks for the next chunk and run within the consumer's task, which is what bounds the memory to one chunk and lets cancelling that task cancel the request. + return AsyncThrowingStream { + try await pass.next() + } + } + + /// + /// List the notes of the authenticated user which changed since a given moment like ``notes(changedSince:)``, but without the text of any of them. + /// + /// The request asks the server to leave out the content of every note it sends in full, which it does through its `exclude` parameter, so the result describes each changed note by everything else, as a ``NoteSummary``, and reduces every other note to its identifier as ``notes(changedSince:)`` does. That suits a client which never needs the text, for example one which only lists titles, or one which deliberately keeps no copy of a note's text and therefore must not download it either. The text of a single note can still be retrieved when it is needed after all, through ``note(_:)``. + /// + /// The server decides what changed exactly as for ``notes(changedSince:)``, and its record of a change covers the text as well, so a note whose text alone changed is listed as changed with a new ``NoteSummary/entityTag``. Everything about the moment to pass, about the result's ``NoteSummaryChanges/lastModified`` and about deriving deletions holds as described there and for ``NoteSummaryChanges``. + /// + /// The server only notices that it cannot read a note while reading its text, which this asks it to skip, so a summary never reports such a failure and has no counterpart to ``Note/hasError``. A note which cannot be read is therefore listed like any other. + /// + /// Every release of the notes app serving ``Notes/minimumAPIVersion`` understands the parameter, and the same requirement and the same failure modes as ``notes(changedSince:)`` apply. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteSummaryChanges/lastModified`` of the previous call, and measured against the server's own record of when it last saw a note change rather than against ``NoteSummary/modification``. + /// + /// - Returns: The summaries of the changed notes, the identifiers of the unchanged ones and what the response headers say about them. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func noteSummaries(changedSince: Date) async throws -> NoteSummaryChanges { + try requireCredentials() + logger.debug("Fetching summaries of notes changed since \(changedSince)...") + + return try await retrieveNoteChanges(changedSince: changedSince) + } + + /// + /// List the notes of the authenticated user which changed since a given moment without their text like ``noteSummaries(changedSince:)``, unless the answer would be the same as the one a given entity tag was taken from. + /// + /// This is to ``noteSummaries(changedSince:)`` what ``notes(changedSince:ifChangedFrom:)`` is to ``notes(changedSince:)``: the request carries the entity tag in its `If-None-Match` header, and the server answers with an empty `304 Not Modified`, which this returns as `nil`, when it would send exactly what it sent when it handed out that tag. + /// + /// Pass the ``NoteSummaryChanges/entityTag`` and the ``NoteSummaryChanges/lastModified`` of the previous result. The server computes the tag from the body it would send, which holds no text here, so a tag taken from a listing with text, such as ``NoteChanges/entityTag``, only matches while neither response sends a note in full. Everything else about the tag, about `nil` and about what to keep for the next call is as described for ``notes(changedSince:ifChangedFrom:)``. + /// + /// The same requirement and the same failure modes as ``noteSummaries(changedSince:)`` apply. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteSummaryChanges/lastModified`` of the previous result. + /// - entityTag: The ``NoteSummaryChanges/entityTag`` of the previous result. + /// + /// - Returns: The summaries of the changed notes, the identifiers of the unchanged ones and what the response headers say about them, or `nil` when the server answered that nothing changed. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func noteSummaries(changedSince: Date, ifChangedFrom entityTag: String) async throws -> NoteSummaryChanges? { + try requireCredentials() + logger.debug("Fetching summaries of notes changed since \(changedSince) unless unchanged...") + + return try await retrieveNoteChanges(changedSince: changedSince, ifChangedFrom: entityTag) + } + + /// + /// Retrieve one chunk of the notes of the authenticated user which changed since a given moment without their text, either the first one of a pass or the one following a given cursor. + /// + /// This is the chunked counterpart of ``noteSummaries(changedSince:)`` and works exactly as ``notes(changedSince:chunkSize:continuingAfter:)`` does, but for the text it asks the server to leave out: every chunk but the last carries a ``NoteSummaryChanges/chunkCursor`` to pass as `cursor` for the next one, and only the last chunk, which ``NoteSummaryChanges/isComplete``, lists the identifiers deletions may be derived from. + /// + /// ``noteSummaryChunks(changedSince:chunkSize:)`` performs a whole pass with this call, as a stream of chunks. + /// + /// The same requirement and the same failure modes as ``noteSummaries(changedSince:)`` apply. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteSummaryChanges/lastModified`` of the previous pass, and the same for every chunk of one pass. + /// - chunkSize: The number of notes to send in full at most, which is raised to one when smaller, because the server takes zero as a request not to split the response at all. + /// - cursor: The ``NoteSummaryChanges/chunkCursor`` of the previous chunk of the same pass, or `nil` to retrieve the first chunk of a new pass. Defaults to `nil`. + /// + /// - Returns: The summaries of the notes of this chunk, the identifiers of the unchanged notes when it is the last chunk, and what the response headers say about them. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func noteSummaries(changedSince: Date, chunkSize: Int, continuingAfter cursor: String? = nil) async throws -> NoteSummaryChanges { + try requireCredentials() + logger.debug("Fetching a chunk of summaries of notes changed since \(changedSince)...") + + return try await retrieveNoteChanges(changedSince: changedSince, chunkSize: chunkSize, continuingAfter: cursor) + } + + /// + /// Retrieve the first chunk of the notes of the authenticated user which changed since a given moment without their text like ``noteSummaries(changedSince:chunkSize:continuingAfter:)``, unless the answer would be the same as the one a given entity tag was taken from. + /// + /// This is to a chunked pass over summaries what ``noteSummaries(changedSince:ifChangedFrom:)`` is to a single response, and it works exactly as ``notes(changedSince:chunkSize:ifChangedFrom:)`` does: pass the ``NoteSummaryChanges/entityTag`` and the ``NoteSummaryChanges/lastModified`` of the last chunk of the previous pass, and continue with ``noteSummaries(changedSince:chunkSize:continuingAfter:)`` when a chunk arrives which is not the last one. + /// + /// The same requirement and the same failure modes as ``noteSummaries(changedSince:chunkSize:continuingAfter:)`` apply. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteSummaryChanges/lastModified`` of the previous pass. + /// - chunkSize: The number of notes to send in full at most, which is raised to one when smaller. + /// - entityTag: The ``NoteSummaryChanges/entityTag`` of the last chunk of the previous pass. + /// + /// - Returns: The first chunk of a new pass, or `nil` when the server answered that nothing changed. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. + /// - Any other error that might occur during retrieval. + /// + func noteSummaries(changedSince: Date, chunkSize: Int, ifChangedFrom entityTag: String) async throws -> NoteSummaryChanges? { + try requireCredentials() + logger.debug("Fetching the first chunk of summaries of notes changed since \(changedSince) unless unchanged...") + + return try await retrieveNoteChanges(changedSince: changedSince, chunkSize: chunkSize, ifChangedFrom: entityTag) + } + + /// + /// Retrieve every chunk of one pass over the notes of the authenticated user which changed since a given moment without their text, in order, as a stream. + /// + /// Each element is the result of one call to ``noteSummaries(changedSince:chunkSize:continuingAfter:)``, the first without a cursor and every further one with the ``NoteSummaryChanges/chunkCursor`` of the element before, and the stream finishes after the element which ``NoteSummaryChanges/isComplete``. It behaves exactly as ``noteChunks(changedSince:chunkSize:)`` does in every other respect: a chunk is only requested when the consumer asks for the next element and within the consumer's task, so cancelling that task cancels the request in flight, a cancellation between two chunks ends the stream without requesting another one, and the stream finishes by throwing the first error a request throws, including ``RainmakerError/credentialsRequired`` before anything is sent when no credentials are set. + /// + /// ```swift + /// var lastChunk: NoteSummaryChanges? + /// + /// for try await chunk in server.noteSummaryChunks(changedSince: store.lastModified ?? .distantPast, chunkSize: 200) { + /// store.upsert(chunk.changed) + /// lastChunk = chunk + /// } + /// + /// // Only the last chunk lists every note the account has, so deletions are derived from it alone. + /// guard let lastChunk, lastChunk.isComplete else { + /// return + /// } + /// + /// store.deleteAll(exceptFor: lastChunk.changed.map(\.id) + lastChunk.unchanged) + /// store.lastModified = lastChunk.lastModified + /// store.entityTag = lastChunk.entityTag + /// ``` + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, usually the ``NoteSummaryChanges/lastModified`` of the previous pass. + /// - chunkSize: The number of notes to send in full at most per chunk, which is raised to one when smaller. + /// + /// - Returns: A stream of the chunks of one pass, the last of which is complete. + /// + func noteSummaryChunks(changedSince: Date, chunkSize: Int) -> AsyncThrowingStream { + logger.debug("Starting a chunked pass over summaries of notes changed since \(changedSince)...") + + let pass = NoteChunkPass { cursor in + try await self.noteSummaries(changedSince: changedSince, chunkSize: chunkSize, continuingAfter: cursor) + } + + // Unfolding rather than producing from a task of its own makes each request wait until the consumer asks for the next chunk and run within the consumer's task, as for the chunks of notes with their text. + return AsyncThrowingStream { + try await pass.next() + } + } + + /// + /// Retrieve a single note of the authenticated user by its identifier. + /// + /// This is what a client asks for when it needs one note as the server has it now, for example to refresh the note being opened, to look at a note it learned the identifier of through ``notes(changedSince:)``, or to perform a single lookup from a Shortcuts action, without listing every note first. The result is the same ``Note`` a listing reports for it, including its ``Note/entityTag``, which ``note(_:ifChangedFrom:)`` takes to skip the transfer while the note did not change. + /// + /// A note which does not exist is reported as ``RainmakerError/notFound``. The notes app answers that not only for an identifier which was never assigned or whose note was deleted, but also for one which belongs to a file outside the notes folder or to a file which is not a note, so this cannot be used to reach arbitrary files by their identifier. An absent notes app is reported as ``RainmakerError/appUnavailable(app:)`` instead, as with ``notes()``, so a client keeping its own copy can tell a deleted note from an app which went away. + /// + /// A note the server could not read is returned like any other rather than failing the call. It carries ``Note/hasError`` and its ``Note/content`` is a message about the failure rather than the note's text, so anything which stores what it retrieves has to check that first. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - id: The ``Note/id`` of the note to retrieve. + /// + /// - Returns: The note as the server has it now. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the account has no note with this identifier. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a note. + /// - Any other error that might occur during retrieval. + /// + func note(_ id: Int) async throws -> Note { + try requireCredentials() + logger.debug("Fetching note \(id)...") + + let request = try makeNotesAPIRequest(for: "notes/\(id)", method: .get) + let (data, _) = try await notesAPIResponse(for: request) + + return try decodeNotesAPIPayload(Note.self, from: data, describing: "the note") + } + + /// + /// Retrieve a single note of the authenticated user by its identifier like ``note(_:)``, unless it is still the one a given entity tag was taken from. + /// + /// The request carries the entity tag in its `If-None-Match` header. While the note is unchanged on the server, the server answers with an empty `304 Not Modified` instead of the note, which this returns as `nil`. That spares a client which checks whether its local copy of a note is stale the transfer of the note's whole content. + /// + /// Pass the ``Note/entityTag`` of the copy at hand, as a listing, ``note(_:)`` or a previous call of this method reported it. The server derives the tag from the note's ``Note/title``, ``Note/category``, ``Note/content``, ``Note/modification``, ``Note/isFavorite`` and ``Note/isReadOnly``, so `nil` means that none of these changed, while a change of anything else, such as the shares reported in ``Note/shareTypes``, goes unnoticed until one of them changes as well. A tag which does not match simply results in the note as ``note(_:)`` would return it. A tag with or without quotes and with a `W/` prefix is accepted alike. + /// + /// A note which no longer exists is reported as ``RainmakerError/notFound`` whatever the tag, so `nil` never hides a deletion. + /// + /// The same requirement and the same failure modes as ``note(_:)`` apply. + /// + /// - Parameters: + /// - id: The ``Note/id`` of the note to retrieve. + /// - entityTag: The ``Note/entityTag`` of the copy at hand. + /// + /// - Returns: The note as the server has it now, or `nil` when the server answered that it did not change. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the account has no note with this identifier. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a note. + /// - Any other error that might occur during retrieval. + /// + func note(_ id: Int, ifChangedFrom entityTag: String) async throws -> Note? { + try requireCredentials() + logger.debug("Fetching note \(id) unless unchanged...") + + // The server only recognizes the tag when the header repeats it quoted and exactly as it computed it, so a weakness marker a proxy may have added is dropped before quoting. + let request = try makeNotesAPIRequest(for: "notes/\(id)", method: .get, headerFields: ["If-None-Match": entityTag.unquotedEntityTag.quotedEntityTag]) + let (data, response) = try await notesAPIResponse(for: request, allowsNotModified: true) + + guard response.status != .notModified else { + logger.debug("Note \(id) did not change.") + return nil + } + + return try decodeNotesAPIPayload(Note.self, from: data, describing: "the note") + } + + /// + /// Create a note for the authenticated user and return it as the server stored it. + /// + /// The server creates the note's file in the account's notes folder, see ``NotesSettings/notesPath``, below the folder `category` names, and returns the new note with the ``Note/id`` and ``Note/entityTag`` it assigned. This is a standalone call which needs nothing but credentials, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of the notes. + /// + /// The title and the category become a file name and a folder, so the server sanitizes both, and a caller has to adopt the ``Note/title``, ``Note/category`` and ``Note/path`` of the result rather than assume what it asked for: + /// + /// - Characters which are illegal in file names on some systems, which are `*`, `|`, `/`, `\`, `:`, `"`, `<`, `>` and `?`, are removed from the title, as are leading dots and white space, so that the file is neither hidden nor placed elsewhere. + /// - Only the first line of the title is kept, any other white space becomes a plain space, and the title is cut off after 100 characters. + /// - A title which ends up empty becomes the notes app's default title, "New note" localized to the account's language. + /// - When the category already holds a note of that title, the server appends a number such as `" (2)"` rather than overwrite it. + /// - Each component of the category, which `/` delimits, is sanitized like the title and empty components are dropped, and the folders it names are created as needed. + /// + /// The title is never derived from the content, which only an outdated version of the notes API did. To give a note the title the notes app's web interface would derive from its text, pass what ``NoteTitle/derive(fromContent:)`` returns as `title`, and to learn beforehand which title and category the server will settle on, see ``NoteTitle/sanitize(_:)`` and ``NoteCategory/sanitize(_:)``. + /// + /// When `modification` is given, the server stamps it onto the note's file after writing the content, so it becomes ``Note/modification``, which is how a client creating a note it wrote offline keeps the moment it was actually written. Without it, or for a moment at or before the Unix epoch, the note is stamped with the moment the server wrote it. + /// + /// > Important: Creating a note is not idempotent. Every call which reaches the server creates another note, even with the same title, which the server then numbers as described above. A call whose response was lost, for example because the connection dropped or because the calling task was cancelled after the request had been sent, may therefore have created a note all the same, and simply repeating it may create a duplicate. Before retrying, list the notes, for example through ``notes(changedSince:)``, and look for the note the first attempt may have created. + /// + /// When the server creates the file but then fails to write the content or the other values, it deletes the new note again before it answers with the error, so a call which fails with a response leaves no half created note behind. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - title: The title of the new note, which the server sanitizes as described above. + /// - category: The category to file the new note under, with `/` delimiting sub-categories, or an empty string for none. Defaults to an empty string. + /// - content: The text of the new note. Defaults to an empty string. + /// - modification: The moment to stamp the new note with as its ``Note/modification``, or `nil` to have the server stamp it with the moment it wrote it. It is sent in whole seconds since the Unix epoch. Defaults to `nil`. + /// - isFavorite: Whether the new note is marked as a favorite. Defaults to `false`. + /// + /// - Returns: The new note as the server stored it, including the sanitized title and category and the identifier and entity tag it assigned. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/insufficientStorage`` when the account's quota leaves no room for the new note. + /// - ``RainmakerError/locked`` when a file the server has to write is locked, after the server already retried for several seconds. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. An outdated release still performs this change before its answer reveals the version, so when this error is thrown the note has already been created. Check ``Notes/isSupported`` first to avoid that. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a note or does not come from the notes app. + /// - Any other error that might occur during the request, such as ``RainmakerError/unexpectedStatus(code:)`` when the server cannot create a file of the sanitized name at all. + /// + func createNote(title: String, category: String = "", content: String = "", modification: Date? = nil, isFavorite: Bool = false) async throws -> Note { + try requireCredentials() + logger.debug("Creating a note...") + + // A moment without a positive number of whole seconds is left out, which the server takes as a request to keep the moment it wrote the file at. + let body = NoteCreationRequest(title: title, category: category, content: content, modified: modification?.wholeSecondsSince1970, favorite: isFavorite) + let request = try makeNotesAPIRequest(for: "notes", method: .post, jsonBody: encodeNotesAPIBody(body)) + let (data, _) = try await notesAPIResponse(for: request) + let note = try decodeNotesAPIPayload(Note.self, from: data, describing: "the created note") + logger.debug("Created note \(note.id).") + + return note + } + + /// + /// Change a note of the authenticated user and return it as the server stored it, optionally only if it is still the one a given entity tag was taken from. + /// + /// Only the values which are given are sent, and the server leaves everything else of the note as it is. A value equal to the one the note already has changes nothing, and a call which gives no value at all returns the note as ``note(_:)`` would. This is a standalone call which needs nothing but credentials and the note's identifier, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of the notes. + /// + /// When `entityTag` is given, the request carries it in its `If-Match` header and the server only changes the note while that is still its ``Note/entityTag``. Otherwise it changes nothing and the call throws ``RainmakerError/noteConflict(current:)``, which carries the note as the server has it now, content and entity tag included. That is what lets a caller resolve the conflict without a further request: apply its change to the current note again, for example append its text to the current ``Note/content`` rather than replace it, and retry with the current note's entity tag. Without an entity tag, the change applies to whatever the note is by then, so the last writer wins. A tag with or without quotes and with a `W/` prefix is accepted alike. + /// + /// The server applies the values one after the other, in the order content, modification, title and category, and favorite, each as a write of its own, so a change is not atomic. When a later step fails, for example because the file is locked or because a title cannot be used as a file name, the earlier steps remain applied, and the error does not say which. Retrieve the note through ``note(_:)`` after an error to learn what it is now. + /// + /// - A new ``Note/content`` replaces the text of the note. + /// - A `modification` is stamped onto the note's file after the content was written, so sending it along with the content keeps the moment the caller wrote the text at, while new content without it is stamped with the moment the server wrote it. A moment at or before the Unix epoch is left out as if it was not given. + /// - A new title renames the note's file and a new category moves it to the matching folder, both sanitized as ``createNote(title:category:content:modification:isFavorite:)`` describes, so a caller has to adopt the ``Note/title``, ``Note/category`` and ``Note/path`` of the result. An empty category moves the note out of every category. The note keeps its ``Note/id`` either way, and on releases of the notes app which keep attachments per note, see ``Notes/storesAttachmentsPerNote``, its `.attachments.` folder moves along. A category folder left empty is removed. + /// - ``Note/isFavorite`` is not stored in the note's file but with the account's favorites, so changing nothing else needs no permission to write the note and works on a note which ``Note/isReadOnly`` as well. It changes the note's entity tag all the same. + /// + /// Every other change of a note which is read-only for the authenticated user, for example one shared without write access, is refused with ``RainmakerError/readOnly``. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - id: The ``Note/id`` of the note to change. + /// - title: The new title, or `nil` to keep the current one. Defaults to `nil`. + /// - category: The new category, with `/` delimiting sub-categories and an empty string for none, or `nil` to keep the current one. Defaults to `nil`. + /// - content: The new text, or `nil` to keep the current one. Defaults to `nil`. + /// - modification: The moment to stamp the note with as its ``Note/modification``, or `nil` to leave it to the server. It is sent in whole seconds since the Unix epoch. Defaults to `nil`. + /// - isFavorite: Whether the note is to be marked as a favorite, or `nil` to keep the current state. Defaults to `nil`. + /// - entityTag: The ``Note/entityTag`` of the copy the change is based on, to change the note only while it is still that copy, or `nil` to change it whatever it is by then. Defaults to `nil`. + /// + /// - Returns: The note as the server stored it after the change, including its new entity tag and the sanitized title and category. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the account has no note with this identifier. + /// - ``RainmakerError/noteConflict(current:)`` when `entityTag` is given but the note changed on the server since, in which case nothing was changed. + /// - ``RainmakerError/readOnly`` when the note cannot be written by the authenticated user. + /// - ``RainmakerError/insufficientStorage`` when the account's quota leaves no room for the new content. + /// - ``RainmakerError/locked`` when the note's file is locked, after the server already retried for several seconds. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. An outdated release still performs this change before its answer reveals the version, so when this error is thrown the note has already been changed. Check ``Notes/isSupported`` first to avoid that. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a note or does not come from the notes app. + /// - Any other error that might occur during the request. + /// + func updateNote(_ id: Int, title: String? = nil, category: String? = nil, content: String? = nil, modification: Date? = nil, isFavorite: Bool? = nil, ifMatching entityTag: String? = nil) async throws -> Note { + try requireCredentials() + logger.debug("Updating note \(id)...") + + var headerFields = [String: String]() + + // The server compares the header against the tag it computed, quoted, so the tag is normalized the same way as for If-None-Match. + if let entityTag { + headerFields["If-Match"] = entityTag.unquotedEntityTag.quotedEntityTag + } + + let body = NoteUpdateRequest(title: title, category: category, content: content, modified: modification?.wholeSecondsSince1970, favorite: isFavorite) + let request = try makeNotesAPIRequest(for: "notes/\(id)", method: .put, headerFields: headerFields, jsonBody: encodeNotesAPIBody(body)) + let (data, _) = try await notesAPIResponse(for: request) + + return try decodeNotesAPIPayload(Note.self, from: data, describing: "the updated note") + } + + /// + /// Delete a note of the authenticated user. + /// + /// The server deletes the note's file like any other file deleted on the server, which puts it into the trash bin when the server keeps one, and it removes the category folder the note leaves empty. On releases of the notes app which keep attachments per note, see ``Notes/storesAttachmentsPerNote``, it deletes the note's `.attachments.` folder along with it, while older releases leave the attachments where they are. This is a standalone call which needs nothing but credentials and the note's identifier, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of the notes. + /// + /// A note which does not exist is reported as ``RainmakerError/notFound``, which a caller deleting a note can take as the note being gone already, for example when it retries a deletion whose response was lost. An absent notes app is reported as ``RainmakerError/appUnavailable(app:)`` instead, as with ``notes()``. + /// + /// Unlike ``updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``, this offers no entity tag to make the deletion conditional on, because the server does not check one when it deletes a note. A caller which must not delete a note changed elsewhere can check through ``note(_:ifChangedFrom:)`` first, which narrows that window but cannot close it. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - id: The ``Note/id`` of the note to delete. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the account has no note with this identifier. + /// - ``RainmakerError/readOnly`` when the note cannot be deleted by the authenticated user. + /// - ``RainmakerError/locked`` when the note's file is locked, after the server already retried for several seconds. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. An outdated release still performs this change before its answer reveals the version, so when this error is thrown the note has already been deleted. Check ``Notes/isSupported`` first to avoid that. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not come from the notes app. + /// - Any other error that might occur during the request. + /// + func deleteNote(_ id: Int) async throws { + try requireCredentials() + logger.debug("Deleting note \(id)...") + + let request = try makeNotesAPIRequest(for: "notes/\(id)", method: .delete) + + // The server answers with an empty list, which carries nothing worth decoding. + _ = try await notesAPIResponse(for: request) + logger.debug("Deleted note \(id).") + } + + /// + /// Retrieve a file a note refers to, such as an image embedded into it, into memory. + /// + /// The path is relative to the folder of the note's category, which is the folder the note's file is in. It is what ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returned, or what the note's text references, after percent-decoding the reference. The server reads `\` as `/`, skips empty components and resolves `..` as a step up, but never above the notes folder, so a path reaches every file in the notes folder and nothing outside of it. Attachments uploaded through the notes app land in a folder named `.attachments.` next to the note on releases which keep them per note, see ``Notes/storesAttachmentsPerNote``, and right next to the note on older releases. ``NoteAttachmentReference/decode(_:)`` turns a reference in the note's text into such a path, and ``NoteAttachmentPath/resolve(_:relativeTo:)`` tells which file in the notes folder the server resolves it to. + /// + /// This is a standalone call which needs nothing but credentials, the note's identifier and the path, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of the notes. It runs within the calling task, so cancelling that task cancels the request. The whole file is held in memory, so a large file or a process with little memory to spare, such as an extension, is better served by ``downloadAttachment(at:ofNote:to:force:)``, which writes it to a local file instead. + /// + /// Unlike every other notes feature, the notes app answers this request without its version header and reports every failure the same way, with `404`: a note which does not exist, a path which names nothing (or, since release 6.1.0, a folder), and a file it cannot read. Releases older than 6.1.0 (see ``Notes/storesAttachmentsPerNote``) fail on a path naming a folder, an empty path included, with an internal error, which is reported as ``RainmakerError/unexpectedStatus(code:)`` with `500`. An absent notes app answers `404` as well, so all of these are reported as ``RainmakerError/notFound`` and cannot be told apart, which is why the ``Notes`` capability is the way to learn whether the app is there. A release of the notes app older than ``Notes/minimumAPIVersion`` does not know the path at all and answers with its version header, which is reported as ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``. + /// + /// The request bypasses the local HTTP cache, because the server allows caching an attachment for an hour while it sends neither an entity tag nor a modification moment which a later request could be made conditional on. Keep what this returns in a store of your own instead, keyed by server, account, note and path. Bypassing the cache does not keep the session from storing the response in its `URLCache` though, which a session whose configuration keeps one on disk does, and a session shared by several accounts should not keep one at all. See ``init(address:password:user:session:webSocket:userAgent:)`` for a fitting configuration. + /// + /// - Parameters: + /// - path: The path of the file relative to the folder of the note's category, as ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returned it or as the note references it after percent-decoding. + /// - noteId: The ``Note/id`` of the note the path is relative to. + /// + /// - Returns: The bytes of the file together with their MIME type. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the note or the file does not exist, the file cannot be read, or the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when the notes app is older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other non-success response, such as `500` for a path naming a folder on a release of the notes app older than 6.1.0. + /// - Any other error that might occur during retrieval. + /// + func attachment(at path: String, ofNote noteId: Int) async throws -> NoteAttachment { + try requireCredentials() + logger.debug("Fetching an attachment of note \(noteId)...") + + let request = try makeAttachmentRetrievalRequest(at: path, ofNote: noteId) + let (data, urlResponse) = try await session.data(for: request) + let response = try validateAttachmentRetrieval(urlResponse) + + return NoteAttachment(data: data, contentType: attachmentContentType(of: response)) + } + + /// + /// Retrieve a file a note refers to, such as an image embedded into it, into a local file. + /// + /// This is the counterpart of ``attachment(at:ofNote:)`` for a file which is not to be held in memory: the session streams the file to disk, and it is put in place only once it arrived completely and successfully, so a failed or cancelled call never leaves a partial file at the destination. Everything about the path, the statuses and caching is the same as there. + /// + /// The destination is the location of the file itself, not of the folder it goes to, and that folder has to exist. When a file exists there already and `force` is not set, the call throws ``RainmakerError/fileAlreadyExists(_:)`` before anything is sent, and once more when such a file appeared while the download was in progress. With `force` set, an existing file is replaced, and it is kept intact until the new one is completely in place. + /// + /// The destination must not be a directory. A directory there is refused with ``RainmakerError/fileAlreadyExists(_:)`` even when `force` is set, before anything is sent and once more right before the file is put in place, so `force` only ever replaces a regular file and never a directory with everything in it. + /// + /// - Parameters: + /// - path: The path of the file relative to the folder of the note's category, as ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returned it or as the note references it after percent-decoding. + /// - noteId: The ``Note/id`` of the note the path is relative to. + /// - destination: The local location to write the file to, in a folder which exists, which must not be a directory itself. + /// - force: Whether to replace a regular file which exists at the destination. Defaults to `false`. + /// + /// - Returns: The location the file was written to, which is `destination`, together with its MIME type. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/fileAlreadyExists(_:)`` when a file exists at the destination and `force` is not set, or when the destination is a directory, whether `force` is set or not. + /// - ``RainmakerError/notFound`` when the note or the file does not exist, the file cannot be read, or the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when the notes app is older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other non-success response, such as `500` for a path naming a folder on a release of the notes app older than 6.1.0. + /// - Any other error that might occur during retrieval or while putting the file in place. + /// + func downloadAttachment(at path: String, ofNote noteId: Int, to destination: URL, force: Bool = false) async throws -> NoteAttachmentFile { + try requireCredentials() + logger.debug("Downloading an attachment of note \(noteId) to \"\(destination.compatibilityPath(percentEncoded: false))\"...") + + // Checked before the download so that a call which cannot succeed sends nothing. A directory is refused even when forced, because replacing it would delete everything in it. + try fileManager.assertNotDirectory(at: destination) + + if force == false { + try fileManager.assertFileDoesNotExist(at: destination) + } + + let request = try makeAttachmentRetrievalRequest(at: path, ofNote: noteId) + let (location, urlResponse) = try await session.download(for: request, delegate: nil) + + // The session leaves the payload in a temporary location, which would otherwise be left behind on every failure below, and which is gone already once the payload was moved on. + defer { + try? fileManager.removeItem(at: location) + } + + let response = try validateAttachmentRetrieval(urlResponse) + + // A file may have appeared at the destination while the download was in progress. + if force == false { + try fileManager.assertFileDoesNotExist(at: destination) + } + + // The payload is staged next to its destination so that putting it in place stays within one volume, see ``URL/downloadStagingLocation()``. + let stagingLocation = destination.downloadStagingLocation() + + defer { + try? fileManager.removeItem(at: stagingLocation) + } + + try fileManager.moveItem(at: location, to: stagingLocation) + + // A directory may have appeared at the destination while the download was in progress, which replacing would delete together with everything in it. + try fileManager.assertNotDirectory(at: destination) + + if fileManager.fileExists(atPath: destination.compatibilityPath(percentEncoded: false)) { + // Replacing keeps the existing file intact until the new one is completely in place, unlike deleting it first. + _ = try fileManager.replaceItemAt(destination, withItemAt: stagingLocation) + } else { + try fileManager.moveItem(at: stagingLocation, to: destination) + } + + return NoteAttachmentFile(location: destination, contentType: attachmentContentType(of: response)) + } + + /// + /// Attach a local file to a note of the authenticated user and return the path the server stored it at. + /// + /// The file is sent as the field `file` of a form, as the web interface of the notes app uploads it. Its body is staged in a temporary file which is copied from `source` through a small buffer and removed again when the call ends, whatever its outcome, so neither the file nor the body is held in memory, while staging temporarily needs free space about the size of the source. The call runs within the calling task, so cancelling that task stops staging the body, which then throws `CancellationError`, or cancels the upload once it is under way. It needs nothing but credentials and the note's identifier, so it suits a single action such as one of Shortcuts as well as a client keeping its own copy of the notes. + /// + /// Where the file ends up and therefore what this returns depends on the release of the notes app, see ``Notes/storesAttachmentsPerNote``: + /// + /// - Releases which keep attachments per note store the file in the folder `.attachments.` next to the note under the given name, appending a number such as `" (1)"` before its extension when the name is taken, and return `.attachments./`. They refuse a name which is not a valid file name on the server with `400`. + /// - Older releases store the file right next to the note under a random name of 32 hexadecimal digits followed by a dot and what follows the last dot of the given name, or all of the given name when it has no dot, and return that name. + /// + /// The server's form handling keeps only what follows the last `/` or `\` of the name, as it does for every uploaded file. The returned path is relative to the folder of the note's category and is what ``attachment(at:ofNote:)``, ``downloadAttachment(at:ofNote:to:force:)`` and ``deleteAttachment(at:ofNote:)`` take. + /// + /// Adding an attachment does not change the note. To embed it, the caller changes the note's content through ``updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` to reference the returned path, for example as `![](.attachments.123/Photo%20%281%29.png)`. The Nextcloud Text app, which edits Markdown notes in the notes app's rich mode, encodes each component of such a reference as JavaScript's `encodeURIComponent` does and additionally encodes `!`, `'`, `(`, `)` and `*`, which keeps a name from ending the Markdown link early; the notes app's plain editor applies `encodeURIComponent` alone. ``NoteAttachmentReference/markdown(alt:path:)`` builds such a reference from the returned path. + /// + /// > Important: Adding an attachment is not idempotent. Every call which reaches the server stores another file, and a call whose response was lost may have stored one all the same. The notes app offers no way to list the attachments of a note, but they are ordinary files, which ``enumerate(at:recursively:)->[Item]`` lists in the folder which contains the file the note's ``Note/path`` names, or in its `.attachments.` subfolder on releases which keep attachments per note, see ``Notes/storesAttachmentsPerNote``. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - source: The local file to attach. + /// - noteId: The ``Note/id`` of the note to attach the file to. + /// - fileName: The name to store the file under, or `nil` for the last component of `source`. Defaults to `nil`. + /// + /// - Returns: The path the server stored the file at, relative to the folder of the note's category. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the account has no note with this identifier. + /// - ``RainmakerError/locked`` when a file the server has to write is locked, after the server already retried for several seconds. + /// - ``RainmakerError/methodNotAllowed`` when the notes app is older than ``Notes/minimumAPIVersion`` and therefore does not offer the upload at all. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when the response does not advertise ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry the stored path or does not come from the notes app. + /// - `CancellationError` when the calling task is cancelled while the body is staged. + /// - Any other error that might occur while staging or sending the file, such as ``RainmakerError/unexpectedStatus(code:)`` with `400` for a name the server refuses and with `500` for a failure to store the file, for example because the quota is exceeded or the note is shared without permission to change it. + /// + func addAttachment(_ source: URL, toNote noteId: Int, fileName: String? = nil) async throws -> String { + try requireCredentials() + logger.debug("Adding an attachment to note \(noteId)...") + + let form = MultipartFormData() + let body = makeMultipartStagingLocation() + + // The staged body is removed whatever the outcome, also when staging it failed halfway. + defer { + try? fileManager.removeItem(at: body) + } + + try form.writeFile(from: source, fieldName: "file", fileName: fileName ?? source.lastPathComponent, to: body, bufferSize: Self.stagingBufferSize) + + // Staging may have taken a while, so a task cancelled meanwhile does not start the upload at all. + try Task.checkCancellation() + + return try await uploadAttachment(stagedAt: body, as: form, toNote: noteId) + } + + /// + /// Attach the given bytes as a file to a note of the authenticated user and return the path the server stored it at. + /// + /// This is the counterpart of ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` for bytes already in memory, for example an image a Shortcuts action was handed, which is why the name to store them under is required. Everything about where the file ends up, what this returns and how it fails is the same as there. The bytes are written into a staged body which is sent through an upload task and removed when the call ends, as there. + /// + /// - Parameters: + /// - data: The bytes to attach. + /// - noteId: The ``Note/id`` of the note to attach the bytes to. + /// - fileName: The name to store the bytes under, whose extension is what the server derives the type of the attachment from. + /// + /// - Returns: The path the server stored the file at, relative to the folder of the note's category. + /// + /// - Throws: The same errors as ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)``. + /// + func addAttachment(_ data: Data, toNote noteId: Int, fileName: String) async throws -> String { + try requireCredentials() + logger.debug("Adding an attachment to note \(noteId)...") + + let form = MultipartFormData() + let body = makeMultipartStagingLocation() + + defer { + try? fileManager.removeItem(at: body) + } + + try form.write(data, fieldName: "file", fileName: fileName, to: body) + + return try await uploadAttachment(stagedAt: body, as: form, toNote: noteId) + } + + /// + /// Delete a file attached to a note of the authenticated user. + /// + /// Only releases of the notes app which keep attachments per note offer this, see ``Notes/supportsAttachmentDeletion``, and older releases answer with ``RainmakerError/methodNotAllowed``. Those releases only delete a file in the note's own `.attachments.` folder: of the path, only its last component counts, which names the file in that folder, so a path such as `.attachments.123/Photo.png` and the bare `Photo.png` delete the same file, and no path reaches any other file. A folder left empty is removed as well. + /// + /// Deleting an attachment does not change the note, so a caller removes any reference to it from the note's content through ``updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` as well. Deleting the note itself deletes all of its attachments along with it on these releases, see ``deleteNote(_:)``. This is a standalone call which needs nothing but credentials, the note's identifier and the path, so it suits a single action such as one of Shortcuts as well. + /// + /// A file which does not exist is reported as ``RainmakerError/notFound``, which a caller deleting an attachment can take as the attachment being gone already. + /// + /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - path: The path of the attachment as ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` returned it, of which only the last component counts. + /// - noteId: The ``Note/id`` of the note the attachment belongs to. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/notFound`` when the note or the attachment does not exist. + /// - ``RainmakerError/methodNotAllowed`` when the notes app does not offer the deletion of attachments, see ``Notes/supportsAttachmentDeletion``. + /// - ``RainmakerError/readOnly`` when the note cannot be changed by the authenticated user. + /// - ``RainmakerError/locked`` when the file is locked, after the server already retried for several seconds. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not come from the notes app. + /// - Any other error that might occur during the request, such as ``RainmakerError/unexpectedStatus(code:)`` with `400` for a last component which is not a valid file name. + /// + func deleteAttachment(at path: String, ofNote noteId: Int) async throws { + try requireCredentials() + logger.debug("Deleting an attachment of note \(noteId)...") + + let request = try makeNotesAPIRequest(for: "attachment/\(noteId)", method: .delete, root: Self.notesAttachmentAPIRoot, queryItems: [URLQueryItem(name: "path", value: path)]) + + // The server answers with an empty list, which carries nothing worth decoding. + _ = try await notesAPIResponse(for: request) + logger.debug("Deleted an attachment of note \(noteId).") + } + + /// + /// Look up the settings the notes app keeps for the authenticated user. + /// + /// These say where the app stores notes and which extension it gives a new one, which matters because notes are ordinary files: the folder is not a fixed name but a value derived from the account's locale by default, so anything which wants to reach notes over WebDAV rather than through ``notes()`` has to ask for it rather than assume it. See ``NotesSettings``. + /// + /// The same requirement and the same failure modes as ``notes()`` apply, since this is the same app's API. + /// + /// - Returns: The notes app's settings for the authenticated user. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry the settings. + /// - Any other error that might occur during retrieval. + /// + func notesSettings() async throws -> NotesSettings { + try requireCredentials() + logger.debug("Fetching note settings...") + + let request = try makeNotesAPIRequest(for: "settings", method: .get) + let (data, _) = try await notesAPIResponse(for: request) + + return try decodeNotesAPIPayload(NotesSettings.self, from: data, describing: "the note settings") + } + + /// + /// Change the settings the notes app keeps for the authenticated user and return them as the server stored them. + /// + /// Only the values which are given are sent, and the server keeps every other setting as it is. A call which gives no value at all changes nothing and returns the settings as ``notesSettings()`` would. This is a standalone call which needs nothing but credentials, so it suits a single action such as one of Shortcuts as well. + /// + /// The settings belong to the account rather than to a client, so a change applies to the app's web interface and to every other client of the account alike. The server validates every value and replaces what it cannot use rather than refusing the request, which is why a caller has to adopt the settings this returns rather than assume what it asked for: + /// + /// - The notes path is relative to the account's files. Both `/` and `\` delimit its components, empty components and `.` are dropped, and `..` removes the component before it, so the path cannot leave the account's files. An empty path means the root folder of the account's files. + /// - A file suffix of `.md` or `.txt` is stored as it is. Any other suffix is stored as a custom one, of which every character other than the letters `A` to `Z` and `a` to `z`, the digits, `.` and `-` is removed, as are its leading dots, before a single dot is put in front of it. A suffix with nothing left becomes `.md`. + /// - A note mode the server does not offer the account, which is ``NoteMode/rich`` when the Text app is not enabled for it, is replaced by the server's default mode. + /// + /// Some settings change which notes the server lists, and a client keeping its own copy of the notes has to treat such a change like a different set of notes and retrieve them anew, for example through ``notes(changedSince:)`` with the Unix epoch: + /// + /// - Changing ``NotesSettings/notesPath`` does not move any note. The notes app looks for notes in the new folder from then on, and creates it when it does not exist yet the next time it is asked for the notes, so the notes in the old folder are no longer listed while those already in the new one are. The ``Notes/notesPath`` the capabilities advertise follows the setting as well. + /// - Changing ``NotesSettings/showsHiddenFiles`` decides whether notes and categories whose names start with a dot are listed, see there. + /// - Changing ``NotesSettings/fileSuffix`` gives the notes created from then on that extension. The app reads files with the extensions `.txt`, `.org`, `.markdown`, `.md` and `.note` as notes whatever the setting, and in addition those with the custom suffix set most recently, so replacing one custom suffix with another one makes the notes of the former disappear from the listings unless their extension is among those. The app only recognizes a custom suffix whose characters after the leading dot are all lower-case and contain no further dot, because it compares the lower-cased last extension of a file name with the suffix as stored; with any other custom suffix, such as `.TXT2` or `.tar.gz`, notes created from then on are neither listed nor found by ``note(_:)``, ``updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`` or ``deleteNote(_:)``, which throw ``RainmakerError/notFound``. + /// + /// The notes app keeps ``NotesSettings/showsHiddenFiles`` and ``NotesSettings/loadsRecentNoteOnStartUp`` since release 6.1.0. Older releases ignore them, which the returned settings show by leaving them `nil`. + /// + /// Credentials are required: the settings are user-scoped and the underlying endpoint rejects unauthenticated requests. + /// + /// - Parameters: + /// - notesPath: The path of the folder to store the notes in, relative to the account's files and sanitized as described above, or `nil` to keep the current one. Defaults to `nil`. + /// - fileSuffix: The file extension to give the notes created from now on, sanitized as described above, or `nil` to keep the current one. Pass a single lower-case extension such as `.txt2`, because the app does not recognize notes with any other custom suffix, see above. Defaults to `nil`. + /// - noteMode: The way the web interface is to present a note when it is opened, or `nil` to keep the current one. Defaults to `nil`. + /// - showsHiddenFiles: Whether files and folders whose names start with a dot are listed as notes and categories, by the API as well as by the web interface, or `nil` to keep the current setting. Defaults to `nil`. + /// - loadsRecentNoteOnStartUp: Whether the web interface is to open the most recently edited note when it starts, or `nil` to keep the current setting. Defaults to `nil`. + /// + /// - Returns: The notes app's settings for the authenticated user as the server stored them after the change, including the sanitized values. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/appUnavailable(app:)`` when the notes app is not available on the server. + /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. An outdated release still performs this change before its answer reveals the version, so when this error is thrown the settings have already been changed. Check ``Notes/isSupported`` first to avoid that. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry the settings or does not come from the notes app. + /// - Any other error that might occur during the request. + /// + func updateNotesSettings(notesPath: String? = nil, fileSuffix: String? = nil, noteMode: NoteMode? = nil, showsHiddenFiles: Bool? = nil, loadsRecentNoteOnStartUp: Bool? = nil) async throws -> NotesSettings { + try requireCredentials() + logger.debug("Updating note settings...") + + // A setting sent as null is reset to its default by the server, so whatever is not given is left out of the body instead. + let body = NotesSettingsUpdateRequest(notesPath: notesPath, fileSuffix: fileSuffix, noteMode: noteMode?.rawValue, showHidden: showsHiddenFiles, loadRecentOnStartUp: loadsRecentNoteOnStartUp) + let request = try makeNotesAPIRequest(for: "settings", method: .put, jsonBody: encodeNotesAPIBody(body)) + let (data, _) = try await notesAPIResponse(for: request) + + return try decodeNotesAPIPayload(NotesSettings.self, from: data, describing: "the updated note settings") + } +} + +// MARK: - Notes API Helpers + +extension Server { + /// + /// Build a request for an endpoint of the notes app's REST API, which every notes feature of ``Server`` sends through ``notesAPIResponse(for:uploadingFrom:allowsNotModified:)``, except for the retrieval of an attachment, whose response ``validateAttachmentRetrieval(_:)`` checks instead. + /// + /// The request is built by ``makeAppRequest(for:method:queryItems:)`` and therefore carries the credentials when there are any, so callers check for them with ``requireCredentials()`` first. + /// Its cache policy is `reloadIgnoringLocalCacheData`, so a response is never answered from a `URLCache`, which could otherwise hide the server's real answer to a conditional request from the caller or serve bytes cached for another account sharing the same session. + /// A JSON body is sent with the matching content type, and the given header fields are applied last so a caller can set conditional headers such as `If-Match` or `If-None-Match`. + /// + /// - Parameters: + /// - path: The path relative to `root`, e.g. `"notes"` or `"settings"`. + /// - method: The HTTP method to use. + /// - root: The root of the API the path is relative to, which is ``notesAPIRoot`` for everything but attachments and ``notesAttachmentAPIRoot`` for them. + /// - queryItems: The query parameters to append, in the order they should appear. + /// - headerFields: Additional header fields to set, which override those set before. + /// - jsonBody: The JSON body to send, usually written by ``encodeNotesAPIBody(_:)``, or `nil` for none. + /// + private func makeNotesAPIRequest(for path: String, method: Method, root: String = Server.notesAPIRoot, queryItems: [URLQueryItem] = [], headerFields: [String: String] = [:], jsonBody: Data? = nil) throws -> URLRequest { + var request = try makeAppRequest(for: root + path, method: method, queryItems: queryItems) + + // A cached response would hide what the server answers now. For a conditional request that is the not modified status the caller asked about, and a session shared between accounts could even be answered with another account's notes. + request.cachePolicy = .reloadIgnoringLocalCacheData + + if let jsonBody { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = jsonBody + } + + for (field, value) in headerFields { + request.setValue(value, forHTTPHeaderField: field) + } + + return request + } + + /// + /// Perform a request built by ``makeNotesAPIRequest(for:method:root:queryItems:headerFields:jsonBody:)`` and map the status of its response onto ``RainmakerError``. + /// + /// This is what every notes feature shares: the mapping of the statuses the notes app answers with onto errors a caller can act upon, and the enforcement of ``Notes/minimumAPIVersion``. + /// + /// The notes app adds its `X-Notes-API-Versions` header to every response it sends itself, see `HTTPURLResponse.notesAPIVersions`, while a response the server or a proxy sends on its behalf lacks it. A status therefore only carries the notes app's meaning when the header is present, and it is checked before the version requirement so that an error the app reports is not mistaken for an outdated app: + /// + /// - `200` without the header is ``RainmakerError/responseDecodingFailed(reason:)``, because it did not come from the notes app, for example a login page of an authenticating proxy or a maintenance page. + /// - `200` with the header is a success, provided the header advertises a supported API version, and ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` otherwise. An outdated release has already handled the request by then, so a change it was asked for has been made although the call throws. + /// - `304` is returned as it is when `allowsNotModified` is set, for a conditional request, and ``RainmakerError/unexpectedStatus(code:)`` otherwise. + /// - `403` with the header is ``RainmakerError/readOnly``. + /// - `404` with the header is ``RainmakerError/notFound``, because the app reports a note or an attachment which does not exist, while `404` without it is ``RainmakerError/appUnavailable(app:)``, because the route itself does not exist. + /// - `405` is ``RainmakerError/methodNotAllowed``, which the server answers before the app is involved, for a method an older release of the app does not route. + /// - `412` with the header and a note as its body is ``RainmakerError/noteConflict(current:)``. + /// - `423` with the header is ``RainmakerError/locked``. + /// - `507` with the header is ``RainmakerError/insufficientStorage``. + /// - Everything else, including `401`, is ``RainmakerError/unexpectedStatus(code:)``, which is what the event stream relies on to recognize rejected credentials. + /// + /// - Parameters: + /// - request: The request to perform. + /// - fileURL: The location of a local file to upload as the request body through an upload task, or `nil` to perform a data task. + /// - allowsNotModified: Whether a `304` response is an expected answer to a conditional request rather than an error. + /// + /// - Returns: The response body and the response itself, whose status is either `200` or, when allowed, `304`. + /// + private func notesAPIResponse(for request: URLRequest, uploadingFrom fileURL: URL? = nil, allowsNotModified: Bool = false) async throws -> (data: Data, response: HTTPURLResponse) { + let data: Data + let urlResponse: URLResponse + + if let fileURL { + (data, urlResponse) = try await session.upload(for: request, fromFile: fileURL, delegate: nil) + } else { + (data, urlResponse) = try await session.data(for: request) + } + + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + let advertisedAPIVersions = response.notesAPIVersions + let isFromNotesApp = advertisedAPIVersions.isEmpty == false + + switch response.status { + case .ok: + // A success without the header was sent by something else on the notes app's behalf, such as a proxy's login page, which must not be mistaken for an outdated app the user is told to update. + guard isFromNotesApp else { + throw RainmakerError.responseDecodingFailed(reason: "The success response lacks the X-Notes-API-Versions header the notes app sends with every response, so it did not come from the notes app.") + } + + // Every response of the notes app advertises which versions of its API it can serve, so the requirement is enforced from the response already in hand rather than by asking for the server's capabilities first. + guard Notes.supports(apiVersions: advertisedAPIVersions) else { + throw RainmakerError.unsupportedAPIVersion(app: Notes.key, required: Notes.minimumAPIVersion, advertised: advertisedAPIVersions) + } + + return (data, response) + + case .notModified where allowsNotModified: + return (data, response) + + case .forbidden where isFromNotesApp: + throw RainmakerError.readOnly + + case .notFound where isFromNotesApp: + throw RainmakerError.notFound + + case .notFound: + // The route only exists while the notes app is installed and enabled, so a not found status the app did not send means the app is not there. Reporting it as such rather than as a missing note keeps a client from deleting its local notes because the app went away. + throw RainmakerError.appUnavailable(app: Notes.key) + + case .methodNotAllowed: + throw RainmakerError.methodNotAllowed + + case .preconditionFailed where isFromNotesApp: + // The notes app sends the note as it currently is along with the conflict, so the caller can resolve it without another request. A body which is not a note leaves nothing to resolve with, so it is reported as the bare status instead. + guard let current = try? jsonDecoder.decode(Note.self, from: data) else { + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + throw RainmakerError.noteConflict(current: current) + + case .locked where isFromNotesApp: + throw RainmakerError.locked + + case .insufficientStorage where isFromNotesApp: + throw RainmakerError.insufficientStorage + + default: + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + } + + /// + /// Build the request ``attachment(at:ofNote:)`` and ``downloadAttachment(at:ofNote:to:force:)`` send for a file a note refers to. + /// + /// It is built by ``makeNotesAPIRequest(for:method:root:queryItems:headerFields:jsonBody:)`` below ``notesAttachmentAPIRoot`` and therefore bypasses the local cache as well, while it announces that it accepts any type, because the response is the file itself rather than JSON. The path is sent as the `path` parameter, percent-encoded through ``makeAppRequest(for:method:queryItems:)`` so that names with characters such as `+` or `&` arrive as they are. + /// + /// - Parameters: + /// - path: The path of the file relative to the folder of the note's category. + /// - noteId: The ``Note/id`` of the note the path is relative to. + /// + private func makeAttachmentRetrievalRequest(at path: String, ofNote noteId: Int) throws -> URLRequest { + try makeNotesAPIRequest(for: "attachment/\(noteId)", method: .get, root: Self.notesAttachmentAPIRoot, queryItems: [URLQueryItem(name: "path", value: path)], headerFields: ["Accept": "*/*"]) + } + + /// + /// Map the status of a response to a request built by ``makeAttachmentRetrievalRequest(at:ofNote:)`` onto ``RainmakerError`` and return the response when it is a success. + /// + /// This differs from ``notesAPIResponse(for:uploadingFrom:allowsNotModified:)`` because the notes app sends the file without its version header and answers every failure with a bare `404`, so neither the version requirement nor the meaning of a status can rest on that header: + /// + /// - `200` is a success. + /// - `404` is ``RainmakerError/notFound``, whether the note, the file or the app itself is missing. + /// - Any other status from a release of the notes app older than ``Notes/minimumAPIVersion``, which recognizes itself by the header advertising only older versions, is ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``, because such a release does not know the path at all. + /// - Everything else is ``RainmakerError/unexpectedStatus(code:)``. + /// + /// - Parameters: + /// - urlResponse: The response to check. + /// + /// - Returns: The response as an `HTTPURLResponse`, whose status is `200`. + /// + private func validateAttachmentRetrieval(_ urlResponse: URLResponse) throws -> HTTPURLResponse { + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + if response.status == .ok { + return response + } + + if response.status == .notFound { + throw RainmakerError.notFound + } + + let advertisedAPIVersions = response.notesAPIVersions + + // An outdated release routes the unknown path to its catch-all, which answers with an error and the versions it serves. + if advertisedAPIVersions.isEmpty == false, Notes.supports(apiVersions: advertisedAPIVersions) == false { + throw RainmakerError.unsupportedAPIVersion(app: Notes.key, required: Notes.minimumAPIVersion, advertised: advertisedAPIVersions) + } + + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + /// + /// The MIME type of a file ``attachment(at:ofNote:)`` or ``downloadAttachment(at:ofNote:to:force:)`` received, as ``NoteAttachment/contentType`` and ``NoteAttachmentFile/contentType`` report it. + /// + /// The notes app always states one, but a response without it is taken to be `application/octet-stream`, as HTTP prescribes for a body of unknown type, rather than failing a call whose file arrived intact. + /// + /// - Parameters: + /// - response: The successful response to read the type from. + /// + private func attachmentContentType(of response: HTTPURLResponse) -> String { + response.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream" + } + + /// + /// A new location in the temporary directory to stage the ``MultipartFormData`` body of an upload of an attachment at, which the caller removes once it has been sent. + /// + private func makeMultipartStagingLocation() -> URL { + fileManager.temporaryDirectory.appendingCompatibility(component: "Rainmaker-\(UUID().uuidString).multipart", directoryHint: .notDirectory) + } + + /// + /// Send a staged ``MultipartFormData`` body as a new attachment of a note and return the path the server stored it at, which both variants of ``addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` end in. + /// + /// The body is sent from its file through an upload task by ``notesAPIResponse(for:uploadingFrom:allowsNotModified:)``, which maps the statuses as for every other notes feature, because the notes app answers an upload with its version header. + /// + /// - Parameters: + /// - body: The location of the staged body. + /// - form: The body, whose boundary the request announces. + /// - noteId: The ``Note/id`` of the note to attach the file to. + /// + private func uploadAttachment(stagedAt body: URL, as form: MultipartFormData, toNote noteId: Int) async throws -> String { + let request = try makeNotesAPIRequest(for: "attachment/\(noteId)", method: .post, root: Self.notesAttachmentAPIRoot, headerFields: ["Content-Type": form.contentType]) + let (data, _) = try await notesAPIResponse(for: request, uploadingFrom: body) + let path = try decodeNotesAPIPayload(AttachmentUploadResponse.self, from: data, describing: "the added attachment").filename + logger.debug("Added an attachment to note \(noteId).") + + return path + } + + /// + /// Retrieve the notes or the summaries of the notes which changed since a given moment, in a single response or as one chunk of a pass, which every unconditional listing of notes and of summaries ends in. + /// + /// Whether the content of the notes is left out follows from the ``NoteChangeSet`` the caller expects, see ``NoteChangeSet/excludesContent``, so ``notes(changedSince:)`` and ``noteSummaries(changedSince:)`` as well as their chunked counterparts differ in nothing but their result type. The caller checks for credentials first. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since. + /// - chunkSize: The number of notes to send in full at most, or `nil` to have the server answer in a single response. + /// - cursor: The cursor of the previous chunk of the same pass, or `nil` for none. + /// + /// - Returns: The listing read from the response. + /// + private func retrieveNoteChanges(changedSince: Date, chunkSize: Int? = nil, continuingAfter cursor: String? = nil) async throws -> Changes { + let request = try makeNoteChangesRequest(changedSince: changedSince, excludesContent: Changes.excludesContent, chunkSize: chunkSize, chunkCursor: cursor) + let (data, response) = try await notesAPIResponse(for: request) + + return try makeNoteChanges(from: data, response: response) + } + + /// + /// Retrieve the notes or the summaries of the notes which changed since a given moment, in a single response or as the first chunk of a pass, unless the answer would be the same as the one a given entity tag was taken from, which every conditional listing of notes and of summaries ends in. + /// + /// The tag is sent in `If-None-Match`, and a `304 Not Modified` is returned as `nil`. Whether the content of the notes is left out follows from the ``NoteChangeSet`` the caller expects, as with ``retrieveNoteChanges(changedSince:chunkSize:continuingAfter:)``. The caller checks for credentials first. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since. + /// - chunkSize: The number of notes to send in full at most, or `nil` to have the server answer in a single response. + /// - entityTag: The entity tag of the previous response, with or without quotes and a `W/` prefix. + /// + /// - Returns: The listing read from the response, or `nil` when the server answered that nothing changed. + /// + private func retrieveNoteChanges(changedSince: Date, chunkSize: Int? = nil, ifChangedFrom entityTag: String) async throws -> Changes? { + // The server only recognizes the tag when the header repeats it quoted and exactly as it computed it, so a weakness marker a proxy may have added is dropped before quoting. + let request = try makeNoteChangesRequest(changedSince: changedSince, excludesContent: Changes.excludesContent, chunkSize: chunkSize, headerFields: ["If-None-Match": entityTag.unquotedEntityTag.quotedEntityTag]) + let (data, response) = try await notesAPIResponse(for: request, allowsNotModified: true) + + guard response.status != .notModified else { + logger.debug("Notes did not change.") + return nil + } + + return try makeNoteChanges(from: data, response: response) + } + + /// + /// Build the request ``notes(changedSince:)``, ``noteSummaries(changedSince:)``, their conditional and their chunked counterparts send for the notes which changed since a given moment. + /// + /// The `category` parameter the server also accepts is never sent, because it narrows the identifiers of the notes which were not sent in full as well, which would make every note outside the category look deleted. + /// When the content is excluded, the `exclude` parameter names nothing but `content`. The title in particular is never excluded, because its presence is what tells a note sent in full from one reduced to its identifier, see `NoteEntry`. + /// + /// - Parameters: + /// - changedSince: The moment to retrieve changes since, sent as the `pruneBefore` parameter in whole seconds since the Unix epoch. + /// - excludesContent: Whether to ask the server to leave out the content of the notes it sends in full, sent as the `exclude` parameter with the value `content`, which is what ``NoteChangeSet/excludesContent`` of the expected result says. + /// - chunkSize: The number of notes to send in full at most, sent as the `chunkSize` parameter after raising it to at least one, or `nil` to have the server answer in a single response. + /// - chunkCursor: The cursor to continue a chunked retrieval from, sent as the `chunkCursor` parameter, or `nil` for none. + /// - headerFields: Additional header fields to set, such as `If-None-Match`. + /// + private func makeNoteChangesRequest(changedSince: Date, excludesContent: Bool, chunkSize: Int? = nil, chunkCursor: String? = nil, headerFields: [String: String] = [:]) throws -> URLRequest { + // A moment at or before the Unix epoch has no positive number of seconds to express it, and pruning before it would exclude nothing anyway, so the server is asked not to prune at all. + let pruneBefore = changedSince.wholeSecondsSince1970 ?? 0 + var queryItems = [URLQueryItem(name: "pruneBefore", value: String(pruneBefore))] + + // Leaving out the content also spares the server reading it for the response, which is why it then cannot notice and report a note it is unable to read. + if excludesContent { + queryItems.append(URLQueryItem(name: "exclude", value: "content")) + } + + // The server takes a chunk size of zero as a request not to split the response, so a caller asking for chunks always gets them, with at least one note per chunk. + if let chunkSize { + queryItems.append(URLQueryItem(name: "chunkSize", value: String(max(1, chunkSize)))) + } + + if let chunkCursor { + queryItems.append(URLQueryItem(name: "chunkCursor", value: chunkCursor)) + } + + return try makeNotesAPIRequest(for: "notes", method: .get, queryItems: queryItems, headerFields: headerFields) + } + + /// + /// Read a successful response to a request built by ``makeNoteChangesRequest(changedSince:excludesContent:chunkSize:chunkCursor:headerFields:)`` into ``NoteChanges`` or ``NoteSummaryChanges``, whichever the caller expects. + /// + /// The body is split into the notes sent in full, decoded as the ``NoteChangeSet/Item`` of the expected result, and the identifiers of those sent as identifiers alone, see `NoteEntry`, while the headers supply the moment, the entity tag, the chunk cursor and the pending count, such as ``NoteChanges/lastModified``, ``NoteChanges/entityTag``, ``NoteChanges/chunkCursor`` and ``NoteChanges/pendingCount``. + /// A header which is absent or cannot be read leaves its value `nil` rather than failing, because the notes themselves are what the caller cannot do without. + /// + /// - Parameters: + /// - data: The response body. + /// - response: The response, whose headers are read. + /// + private func makeNoteChanges(from data: Data, response: HTTPURLResponse) throws -> Changes { + let entries = try decodeNotesAPIPayload([NoteEntry].self, from: data, describing: "the notes") + + var changed = [Changes.Item]() + var unchanged = [Int]() + + for entry in entries { + switch entry { + case let .changed(item): changed.append(item) + case let .unchanged(id): unchanged.append(id) + } + } + + let lastModified = response.value(forHTTPHeaderField: "Last-Modified").flatMap(Date.init(httpDate:)) + let entityTag = response.value(forHTTPHeaderField: "ETag").map(\.unquotedEntityTag) + let chunkCursor = response.value(forHTTPHeaderField: "X-Notes-Chunk-Cursor").flatMap { $0.isEmpty ? nil : $0 } + let pendingCount = response.value(forHTTPHeaderField: "X-Notes-Chunk-Pending").flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + + return Changes(changed: changed, unchanged: unchanged, lastModified: lastModified, entityTag: entityTag, chunkCursor: chunkCursor, pendingCount: pendingCount) + } + + /// + /// Decode the body of a successful response from ``notesAPIResponse(for:uploadingFrom:allowsNotModified:)`` with ``jsonDecoder``. + /// + /// None of the notes app's endpoints answers with an OCS envelope, so unlike every other JSON endpoint in this library there is no `meta` status vouching for a payload. A success response which carries the notes app's version header but a body other than the expected one therefore has to surface as ``RainmakerError/responseDecodingFailed(reason:)`` rather than as an opaque Foundation error. An HTML login or maintenance page served by a proxy lacks that header and is reported the same way already by ``notesAPIResponse(for:uploadingFrom:allowsNotModified:)``, before it reaches this. The payload is left out of that message so that note contents cannot leak into logs. + /// + /// - Parameters: + /// - type: The type to decode. + /// - data: The response body. + /// - subject: What is being decoded, for the error message, e.g. `"the notes"`. + /// + private func decodeNotesAPIPayload(_ type: Value.Type, from data: Data, describing subject: String) throws -> Value { + do { + return try jsonDecoder.decode(type, from: data) + } catch { + throw RainmakerError.responseDecodingFailed(reason: "Failed to decode \(subject): \(error)") + } + } + + /// + /// Encode a request body for the notes app's REST API with ``jsonEncoder``, to be passed as the JSON body to ``makeNotesAPIRequest(for:method:root:queryItems:headerFields:jsonBody:)``. + /// + /// - Parameters: + /// - value: The value to encode, usually a data transfer object shaped as the notes app expects it. + /// + private func encodeNotesAPIBody(_ value: some Encodable) throws -> Data { + try jsonEncoder.encode(value) + } +} diff --git a/Sources/Rainmaker/Server.swift b/Sources/Rainmaker/Server.swift index 760df8a..ed03863 100644 --- a/Sources/Rainmaker/Server.swift +++ b/Sources/Rainmaker/Server.swift @@ -17,6 +17,21 @@ public final class Server { /// static let activityLimits = 1 ... 200 + /// + /// The path of the notes app's REST API relative to ``appsAddress``, which every notes endpoint except those for attachments is resolved against. + /// + /// The `v1` segment selects the major version of the API, while the minor version the installed app serves is only learned from the `X-Notes-API-Versions` header of each response and checked against ``Notes/minimumAPIVersion``. + /// Endpoints for attachments are resolved against ``notesAttachmentAPIRoot`` instead. + /// + static let notesAPIRoot = "notes/api/v1/" + + /// + /// The path of the notes app's attachment endpoints relative to ``appsAddress``, which differs from ``notesAPIRoot`` in naming the minor version as well. + /// + /// Releases of the notes app before 6.1 route their attachment endpoints only below the `v1.4` segment and answer the same paths below `v1` with an error, while every release advertising API version 1.4 accepts this one. + /// + static let notesAttachmentAPIRoot = "notes/api/v1.4/" + /// /// The chunk size ``upload(_:to:force:chunkSize:)`` uses when the caller does not choose one, which is 10 MiB. /// @@ -47,8 +62,30 @@ public final class Server { nonisolated(unsafe) let fileManager = FileManager.default let logger = Logger(category: "Server") + + /// + /// The decoder every JSON response body is read with, shared across calls rather than created per endpoint. + /// + /// It is configured once in ``init(address:password:user:session:webSocket:userAgent:)`` and never changed afterwards, which is what keeps it safe to share across concurrent calls. + /// Its counterpart for request bodies is ``jsonEncoder``. + /// let jsonDecoder: JSONDecoder + + /// + /// The encoder every JSON request body is written with, the counterpart of ``jsonDecoder`` for what is sent rather than what is received. + /// + /// It is configured once in ``init(address:password:user:session:webSocket:userAgent:)`` and never changed afterwards, for the same reason as ``jsonDecoder``. + /// The notes app is the first API this library sends JSON to, and the request bodies for it encode their dates themselves in the form that app expects, so the date strategy set here only keeps both directions consistent for whatever does not. + /// + let jsonEncoder: JSONEncoder + let session: any Requesting + + /// + /// Opens the `notify_push` WebSocket for ``events(_:)``, through which ``ServerEventCoordinator`` hands it to each ``PushNotificationsConnection``. + /// + /// It is never used with the ``ServerEventTransport/polling`` transport or on watchOS, where ``ServerEventCoordinator/platformSupportsWebSocket`` is `false`. + /// let webSocket: any WebSocketConnecting /// @@ -62,7 +99,10 @@ public final class Server { public let password: String? /// - /// The Nextcloud user name used to identify as. + /// The Nextcloud login name used to identify as, together with ``password``. + /// + /// This is also what the WebDAV paths of this library are built from, which only works for accounts whose login name is their identifier. + /// A server can accept an email address or a login attribute of an LDAP directory as the login name instead, and ``currentUser()`` returns the identifier the server actually keys the account by. /// public let user: String? @@ -128,55 +168,16 @@ public final class Server { /// /// Helper method which ensures this object was setup up with a user name and password. /// + /// It is internal rather than private because the extensions of ``Server`` in other files, such as the notes features in `Server+Notes.swift`, guard their calls with it as well. + /// /// - Throws: If this is called and the ``user`` or ``password`` are not defined. /// - private func requireCredentials() throws { + func requireCredentials() throws { guard user != nil, password != nil else { throw RainmakerError.credentialsRequired } } - /// - /// Request an endpoint of the notes app's API and return its raw payload. - /// - /// This is what every notes feature shares: the base path, the mapping of an absent app onto a not found error, and the enforcement of ``Notes/minimumAPIVersion``. - /// - /// None of those endpoints answers with an OCS envelope, so unlike every other JSON endpoint in this library there is no `meta` status vouching for a payload. A success response carrying something else entirely, for example an HTML login or maintenance page served by a proxy, therefore has to surface as ``RainmakerError/responseDecodingFailed(reason:)`` rather than as an opaque Foundation error, which is why every caller wraps its decoding. The payload is left out of those messages so that note contents cannot leak into logs. - /// - /// - Parameters: - /// - path: The path relative to the notes app's API root, e.g. `"notes"` or `"settings"`. - /// - queryItems: The query parameters to append, in the order they should appear. - /// - private func notesAPIPayload(for path: String, queryItems: [URLQueryItem] = []) async throws -> Data { - let request = try makeAppRequest(for: "notes/api/v1/\(path)", method: .get, queryItems: queryItems) - let (data, urlResponse) = try await session.data(for: request) - - guard let response = urlResponse as? HTTPURLResponse else { - throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") - } - - // The endpoint only exists while the notes app is installed and enabled, so its absence surfaces as a not found error. A notes app too old to serve this major version of the API is reported the same way. - if response.status == .notFound { - throw RainmakerError.notFound - } - - guard response.status == .ok else { - throw RainmakerError.unexpectedStatus(code: response.statusCode) - } - - // Every response of the notes API advertises which versions of it the installed app can serve, so the requirement is enforced from the response already in hand rather than by asking for the server's capabilities first. - let advertisedAPIVersions = (response.value(forHTTPHeaderField: "X-Notes-API-Versions") ?? "") - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { $0.isEmpty == false } - - guard Notes.supports(apiVersions: advertisedAPIVersions) else { - throw RainmakerError.unsupportedAPIVersion(app: Notes.key, required: Notes.minimumAPIVersion, advertised: advertisedAPIVersions) - } - - return data - } - /// /// List the content of the remote directory. /// @@ -860,12 +861,26 @@ public final class Server { /// /// Create a new server object. /// + /// Every request authenticates itself and refuses cookies, so one session can be shared by the ``Server`` objects of several accounts. Requests which must not be answered from a cache, such as those of the notes features and the retrieval of avatars and of attachments through ``attachment(at:ofNote:)``, bypass the session's `URLCache`, but bypassing it does not keep the session from storing their responses in it. A session's cache is keyed by URL alone, while the same URL may stand for different files for different accounts, because the path of an attachment is resolved relative to the account's own folders, and the default configuration of `URLSession` keeps its cache on disk, where the attachments of private notes would outlive the session. A session shared by several accounts, or one which handles private files, should therefore keep no cache at all: + /// + /// ```swift + /// let configuration = URLSessionConfiguration.ephemeral + /// configuration.urlCache = nil + /// configuration.httpCookieStorage = nil + /// configuration.httpShouldSetCookies = false + /// + /// let session = URLSession(configuration: configuration) + /// let server = Server(address: address, password: password, user: user, session: session) + /// ``` + /// + /// The ephemeral session this creates when none is given keeps its cache in memory only and is not shared with any other ``Server``. + /// /// - Parameters: /// - address: HTTP address of the Nextcloud host. /// - password: In most cases, this is the app password and not the account password. /// - user: The Nextcloud user name used to identify as. /// - session: A ``Requesting`` object (e.g. a `URLSession`) to use for network requests. Defaults to a new ephemeral `URLSession`. - /// - webSocket: A ``WebSocketConnecting`` object (e.g. a `URLSession`) to open the `notify_push` WebSocket with, used by ``events(_:)``. It is a separate parameter from `session` only because a `URLSession` typed as `any Requesting` does not expose its WebSocket features. Defaults to a new ephemeral `URLSession`, matching `session`, since notify_push authenticates over the socket itself and needs no persistent cookie, credential, or cache storage. + /// - webSocket: A ``WebSocketConnecting`` object (e.g. a `URLSession`) to open the `notify_push` WebSocket with, used by ``events(_:)`` with the ``ServerEventTransport/automatic`` transport on every platform but watchOS. It is a separate parameter from `session` only because a `URLSession` typed as `any Requesting` does not expose its WebSocket features. Defaults to a new ephemeral `URLSession`, matching `session`, since notify_push authenticates over the socket itself and needs no persistent cookie, credential, or cache storage. /// - userAgent: The user agent to report as in HTTP request headers. /// public init(address: URL, password: String? = nil, user: String? = nil, session: any Requesting = URLSession(configuration: .ephemeral), webSocket: any WebSocketConnecting = URLSession(configuration: .ephemeral), userAgent: String = "Rainmaker") { @@ -875,6 +890,10 @@ public final class Server { // Every JSON date the server sends is ISO 8601, so the strategy belongs on the shared decoder rather than on a throwaway one per endpoint. It is set once here and never changed afterwards, which keeps the decoder as safe to share across calls as it already was. jsonDecoder.dateDecodingStrategy = .iso8601 + // The encoder mirrors the decoder for the same reasons, so a date round-trips in the form the server reads and writes elsewhere. + jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .iso8601 + self.password = password self.session = session self.webSocket = webSocket @@ -1357,17 +1376,48 @@ extension Server: Serving { } /// - /// Look up the login flow information. + /// Begin a login flow, which yields an app password once the user granted access in a browser. /// - /// - Returns: A set of properties to kick off the authentication which yields an app password. + /// This is the first step of the [login flow v2](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html), which needs no credentials and is therefore available on a ``Server`` created without ``user`` and ``password``. + /// Present ``LoginFlow/entry`` to the user in a browser, then call ``poll(_:)`` with the returned ``LoginFlow`` repeatedly until it returns a ``LoginResult``. + /// The server forgets a flow 20 minutes after it began, so a client should stop polling by then and begin a new flow if the user still wants to log in. + /// + /// This is a single request which runs within the calling task, so cancelling that task cancels it. + /// + /// - Returns: The addresses and the token of the new login flow. + /// + /// - Throws: + /// - ``RainmakerError/notFound`` when the server answers that it does not know the endpoint, which is the case for an address which does not point to the root of a Nextcloud server. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other status than `200`. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when the server answers with something other than a login flow, for example the HTML page of a web server which is not Nextcloud. /// public func login() async throws -> LoginFlow { logger.debug("Fetching login information...") let url = address.appendingCompatibility(path: "index.php/login/v2", directoryHint: .notDirectory) let request = makeRequest(for: url, method: .post) - let (data, _) = try await session.data(for: request) - let dataTransferObject = try jsonDecoder.decode(LoginFlowResponse.self, from: data) + let (data, urlResponse) = try await session.data(for: request) + + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + if response.status == .notFound { + throw RainmakerError.notFound + } + + guard response.status == .ok else { + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + let dataTransferObject: LoginFlowResponse + + do { + dataTransferObject = try jsonDecoder.decode(LoginFlowResponse.self, from: data) + } catch { + throw RainmakerError.responseDecodingFailed(reason: "The server did not answer with a login flow, so it is probably not a Nextcloud server.") + } + return LoginFlow(endpoint: dataTransferObject.poll.endpoint, entry: dataTransferObject.login, token: dataTransferObject.poll.token) } @@ -1457,6 +1507,54 @@ extension Server: Serving { return envelope.ocs.data } + /// + /// Fetch the identifier and the display name of the account this ``Server`` authenticates as. + /// + /// The identifier of an account is not necessarily the name it logs in with: a server can accept an email address or a login attribute of an LDAP directory as the login name, which is what ``user`` and ``LoginResult/name`` then hold, while the server keys the account by an identifier of its own. + /// That identifier is what ``User/id`` carries here, and it is what the server expects wherever it names an account, so pass it rather than the login name to ``userAvatar(_:size:darkTheme:)``. + /// The WebDAV paths of this library are still built from ``user`` and therefore only work for accounts whose login name is their identifier, which this method does not change. + /// + /// This is a single request without any state, so it can be called on its own, for example once after a login flow completed, and cancelling the calling task cancels it. + /// + /// - Returns: The account as a ``User`` with its identifier and its display name, which the server falls back to the identifier for when the account has no display name of its own. + /// + /// - Throws: + /// - ``RainmakerError/credentialsRequired`` when no credentials are set. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any non-success response, such as `401` when the server rejects the credentials. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when the server answers with something other than the details of an account. + /// - Any other error that might occur during retrieval. + /// + public func currentUser() async throws -> User { + try requireCredentials() + logger.debug("Fetching the current user...") + + let request = try makeOCSRequest(for: "cloud/user", method: .get) + let (data, urlResponse) = try await session.data(for: request) + + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + // Unlike the endpoints of optional apps, this one is part of every installation, so a missing route is not a condition of its own and every other status, the rejected credentials included, surfaces as such. + guard response.status == .ok else { + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + let envelope: CurrentUserResponse + + do { + envelope = try jsonDecoder.decode(CurrentUserResponse.self, from: data) + } catch { + throw RainmakerError.responseDecodingFailed(reason: "The server did not answer with the details of the current user.") + } + + guard envelope.ocs.meta.status == "ok" else { + throw RainmakerError.responseDecodingFailed(reason: "OCS request failed (\(envelope.ocs.meta.statuscode)): \(envelope.ocs.meta.message ?? "No message.")") + } + + return User(id: envelope.ocs.data.id, displayName: envelope.ocs.data.displayname) + } + /// /// List the notifications currently queued for the authenticated user. /// @@ -1626,7 +1724,7 @@ extension Server: Serving { /// This is a front page route rather than an OCS or app one, and the server marks it as public. Credentials are required here regardless: an instance may be configured to refuse anonymous requests outright, and asking as the signed-in account is what makes the call behave the same on every instance rather than only on the permissive ones. /// /// - Parameters: - /// - userId: The identifier of the user to retrieve the avatar of, which is their login name rather than their display name. + /// - userId: The identifier of the user to retrieve the avatar of, which is neither their display name nor necessarily the name they log in with. For the authenticated account it is the ``User/id`` returned by ``currentUser()``, not ``user`` or ``LoginResult/name``. /// - size: Which of the two served sizes to ask for. Defaults to ``AvatarSize/small``. /// - darkTheme: Whether to retrieve the variant meant for a dark appearance. Defaults to `false`. /// @@ -1785,125 +1883,6 @@ extension Server: Serving { return envelope.ocs.data.pages } - /// - /// List all notes of the authenticated user. - /// - /// Notes are provided by the server's notes app which, unlike most of what this library covers, is not part of a Nextcloud installation and has to be installed separately. Whether it is available can be checked in advance via the ``Notes`` capability, e.g. `try await capabilities().contains(Notes.self)`. When the app is unavailable the underlying endpoint does not exist and this call throws ``RainmakerError/notFound``. - /// - /// The very same not found error is what a server answers whose `index.php` routing is disabled or whose reverse proxy swallows the route, so those causes cannot be told apart from the response alone. - /// - /// An app which is installed but older than ``Notes/minimumAPIVersion`` is reported separately, as ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)``. That requirement is checked on every response, because the notes API advertises the versions it serves in a header of its own, and it can be checked in advance through ``Notes/isSupported``. - /// - /// The whole collection is retrieved in a single request, because the endpoint returns everything at once unless a chunk size is requested, which this deliberately does not do. Use ``notes(changedSince:)`` to retrieve only what changed since an earlier call. - /// - /// > Warning: Every note including its full content is fetched and held in memory at once, so what this costs grows with the size of the account's notes. - /// - /// A note the server could not read is listed like any other and does not fail the call. It carries ``Note/hasError`` and its ``Note/content`` is a message about the failure rather than the note's text, so anything which stores what it retrieves has to check that first. - /// - /// Credentials are required: notes are user-scoped and the underlying endpoint rejects unauthenticated requests. - /// - /// - Returns: The notes in the order returned by the server. - /// - /// - Throws: - /// - ``RainmakerError/credentialsRequired`` when no credentials are set. - /// - ``RainmakerError/notFound`` when the notes app is not available on the server. - /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. - /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. - /// - Any other error that might occur during retrieval. - /// - public func notes() async throws -> [Note] { - try requireCredentials() - logger.debug("Fetching notes...") - - let data = try await notesAPIPayload(for: "notes") - - do { - return try jsonDecoder.decode([Note].self, from: data) - } catch { - throw RainmakerError.responseDecodingFailed(reason: "Failed to decode the notes: \(error)") - } - } - - /// - /// List the notes of the authenticated user which changed since a given moment, together with the identifiers of those which did not. - /// - /// This is the incremental counterpart of ``notes()`` for a client keeping its own copy of the notes: the server returns every note it recorded a change for at or after `changedSince` in full, and reduces every note it did not to its identifier alone. Both together are the complete set of notes the account has, which is what makes deletions detectable. See ``NoteChanges`` for how the two halves are meant to be applied. - /// - /// The moment is sent to the server as its `pruneBefore` parameter, converted to whole seconds since the Unix epoch. A moment at or before the epoch prunes nothing and therefore behaves like ``notes()``. - /// - /// > Warning: The server compares this moment against its own record of when it last noticed each note change, which is not the same as that note's ``Note/modification`` date. A note may be from 2020, but when the server only found it today it is not pruned from the response. Never pass a note's ``Note/modification`` back in as this moment; pass one measured on the same clock the server runs on instead, such as when the previous retrieval was made. The API defines the exact value to reuse as the `Last-Modified` header of the previous response, which is the server's own request time and which this library does not surface. - /// - /// Everything else, including how an unavailable app surfaces and how a note the server could not read is reported, matches ``notes()``. - /// - /// - Parameters: - /// - changedSince: The moment to retrieve changes since, measured against the server's own record of when it last saw a note change rather than against ``Note/modification``. - /// - /// - Returns: The changed notes and the identifiers of the unchanged ones. - /// - /// - Throws: - /// - ``RainmakerError/credentialsRequired`` when no credentials are set. - /// - ``RainmakerError/notFound`` when the notes app is not available on the server. - /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. - /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry a list of notes. - /// - Any other error that might occur during retrieval. - /// - public func notes(changedSince: Date) async throws -> NoteChanges { - try requireCredentials() - logger.debug("Fetching notes changed since \(changedSince)...") - - // A moment at or before the Unix epoch has no positive number of seconds to express it, and pruning before it would exclude nothing anyway, so the server is asked not to prune at all. - let pruneBefore = changedSince.wholeSecondsSince1970 ?? 0 - let data = try await notesAPIPayload(for: "notes", queryItems: [URLQueryItem(name: "pruneBefore", value: String(pruneBefore))]) - let entries: [NoteEntry] - - do { - entries = try jsonDecoder.decode([NoteEntry].self, from: data) - } catch { - throw RainmakerError.responseDecodingFailed(reason: "Failed to decode the notes: \(error)") - } - - var changed = [Note]() - var unchanged = [Int]() - - for entry in entries { - switch entry { - case let .changed(note): changed.append(note) - case let .unchanged(id): unchanged.append(id) - } - } - - return NoteChanges(changed: changed, unchanged: unchanged) - } - - /// - /// Look up the settings the notes app keeps for the authenticated user. - /// - /// These say where the app stores notes and which extension it gives a new one, which matters because notes are ordinary files: the folder is not a fixed name but a value derived from the account's locale by default, so anything which wants to reach notes over WebDAV rather than through ``notes()`` has to ask for it rather than assume it. See ``NotesSettings``. - /// - /// The same requirement and the same failure modes as ``notes()`` apply, since this is the same app's API. - /// - /// - Returns: The notes app's settings for the authenticated user. - /// - /// - Throws: - /// - ``RainmakerError/credentialsRequired`` when no credentials are set. - /// - ``RainmakerError/notFound`` when the notes app is not available on the server. - /// - ``RainmakerError/unsupportedAPIVersion(app:required:advertised:)`` when it is available but older than ``Notes/minimumAPIVersion``. - /// - ``RainmakerError/responseDecodingFailed(reason:)`` when a success response does not carry the settings. - /// - Any other error that might occur during retrieval. - /// - public func notesSettings() async throws -> NotesSettings { - try requireCredentials() - logger.debug("Fetching note settings...") - - let data = try await notesAPIPayload(for: "settings") - - do { - return try jsonDecoder.decode(NotesSettings.self, from: data) - } catch { - throw RainmakerError.responseDecodingFailed(reason: "Failed to decode the note settings: \(error)") - } - } - /// /// Retrieve one page of the activity stream the server records for the authenticated user. /// @@ -2040,6 +2019,8 @@ extension Server: Serving { /// /// `queryItems` defaults to an empty array, so endpoints which are parameterized through the path alone are requested without naming it. Passing an empty array produces exactly the URL a call without any query would. /// + /// The names and values of `queryItems` are passed without any percent-encoding and reach the server exactly as given. Every character a query may not contain, such as `#`, is percent-encoded, and so are `+`, `&` and `=`, which a query may contain but which the server would read as a space or as delimiters, so that a value such as the file name `a+b.png` does not arrive as `a b.png`. + /// /// - Parameters: /// - path: The path relative to the OCS root, e.g. `"apps/activity/api/v2/activity/all"`. /// - method: The HTTP method to use. @@ -2050,8 +2031,9 @@ extension Server: Serving { var components = URLComponents(url: url, resolvingAgainstBaseURL: false) // The query is only assigned when there is one so that a request without query parameters produces a bare URL rather than one with a trailing question mark. + // Characters which are legal in a query but which the server reads as delimiters or as a space, such as `+`, are percent-encoded as well, so every value reaches the server as given. if queryItems.isEmpty == false { - components?.queryItems = queryItems + components?.setEncodedQueryItems(queryItems) } var request = makeRequest(for: components?.url ?? url, method: method) @@ -2074,6 +2056,8 @@ extension Server: Serving { /// /// `queryItems` defaults to an empty array, so endpoints which are parameterized through the path alone are requested without naming it. Passing an empty array produces exactly the URL a call without any query would. /// + /// The names and values of `queryItems` are passed without any percent-encoding and reach the server exactly as given. Every character a query may not contain, such as `#`, is percent-encoded, and so are `+`, `&` and `=`, which a query may contain but which the server would read as a space or as delimiters, so that a value such as the file name `a+b.png` does not arrive as `a b.png`. + /// /// - Parameters: /// - path: The path relative to the apps root (see ``Server/appsAddress``), e.g. `"notes/api/v1/notes"`. /// - method: The HTTP method to use. @@ -2084,8 +2068,9 @@ extension Server: Serving { var components = URLComponents(url: url, resolvingAgainstBaseURL: false) // The query is only assigned when there is one so that a request without query parameters produces a bare URL rather than one with a trailing question mark. + // Characters which are legal in a query but which the server reads as delimiters or as a space, such as `+`, are percent-encoded as well, so every value reaches the server as given. if queryItems.isEmpty == false { - components?.queryItems = queryItems + components?.setEncodedQueryItems(queryItems) } var request = makeRequest(for: components?.url ?? url, method: method) @@ -2111,19 +2096,71 @@ extension Server: Serving { try makeWebDAVRequest(for: webDAVAddress.appendingCompatibility(path: path, directoryHint: .inferFromPath), method: method) } + /// + /// Check once whether the user completed a login flow begun with ``login()``, and fetch its result if so. + /// + /// Call this repeatedly, for example every second, until it returns a ``LoginResult`` or the caller gives up. + /// The server answers a flow the user has not completed yet with the status `404` and an empty JSON array, for which this returns `nil` rather than throwing, so that a caller can tell a pending flow from a real failure. + /// A caller which treats every thrown error as pending, as a `try?` does, keeps polling a server which is unreachable or which rejects the request until it gives up, and never shows the user why. + /// + /// The server cannot tell a pending flow from one it does not know, so it answers a flow which expired, whose token is wrong or whose result was already fetched exactly like a pending one, and this returns `nil` for those as well. + /// The server forgets a flow 20 minutes after ``login()`` began it, and hands out its result only once, so a caller should bound its polling by such a time and stop polling after the first result. + /// + /// This is a single request which needs no credentials and runs within the calling task, so cancelling that task cancels it. + /// + /// - Parameters: + /// - flow: The login flow as returned by ``login()``, of which only ``LoginFlow/endpoint`` and ``LoginFlow/token`` are used. + /// + /// - Returns: The credentials the user granted, or `nil` while the flow is pending. + /// + /// - Throws: + /// - ``RainmakerError/notFound`` when the server answers with the status `404` but without the empty JSON array, which means the endpoint does not exist rather than that the flow is pending. + /// - ``RainmakerError/unexpectedStatus(code:)`` for any other status than `200` and `404`. + /// - ``RainmakerError/responseDecodingFailed(reason:)`` when the server answers with the status `200` but without the credentials. + /// + public func poll(_ flow: LoginFlow) async throws -> LoginResult? { + let (data, urlResponse) = try await sendLoginPoll(to: flow.endpoint, token: flow.token) + + guard let response = urlResponse as? HTTPURLResponse else { + throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.") + } + + if response.status == .notFound { + guard let pending = try? jsonDecoder.decode([String].self, from: data), pending.isEmpty else { + throw RainmakerError.notFound + } + + return nil + } + + guard response.status == .ok else { + throw RainmakerError.unexpectedStatus(code: response.statusCode) + } + + let dataTransferObject: LoginResultResponse + + do { + dataTransferObject = try jsonDecoder.decode(LoginResultResponse.self, from: data) + } catch { + throw RainmakerError.responseDecodingFailed(reason: "The server did not answer with the result of the login flow.") + } + + return LoginResult(name: dataTransferObject.loginName, password: dataTransferObject.appPassword, server: dataTransferObject.server) + } + /// /// Poll the status of a login flow. /// + /// Unlike ``poll(_:)``, this throws ``RainmakerError/responseDecodingFailed(reason:)`` while the flow is pending, which a caller cannot tell from a real failure, and it ignores the status of the response. + /// It is kept unchanged for existing callers. + /// /// - Parameters: /// - endpoint: The URL to poll on. /// - token: The unique token of the login flow to check the status of. /// + @available(*, deprecated, renamed: "poll(_:)", message: "Use poll(_:) with the LoginFlow, which returns nil while the flow is pending instead of throwing.") public func poll(_ endpoint: URL, token: String) async throws -> LoginResult { - logger.debug("Polling \(endpoint.absoluteString)") - - var request = makeRequest(for: endpoint, method: .post) - request.httpBody = "token=\(token)".data(using: .utf8) - let (data, _) = try await session.data(for: request) + let (data, _) = try await sendLoginPoll(to: endpoint, token: token) let stringRepresentation = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) guard stringRepresentation != "[]" else { @@ -2134,6 +2171,25 @@ extension Server: Serving { return LoginResult(name: dataTransferObject.loginName, password: dataTransferObject.appPassword, server: dataTransferObject.server) } + /// + /// Send the request which polls a login flow, shared by ``poll(_:)`` and its deprecated predecessor ``poll(_:token:)``. + /// + /// The token is sent as a form field, percent-encoded like a query item through `URLComponents.setEncodedQueryItems(_:)`, because the server reads the body as `application/x-www-form-urlencoded`. + /// The tokens the server hands out consist of letters and digits only, so the body is the same as before the encoding was added. + /// + private func sendLoginPoll(to endpoint: URL, token: String) async throws -> (Data, URLResponse) { + logger.debug("Polling \(endpoint.absoluteString)") + + var form = URLComponents() + form.setEncodedQueryItems([URLQueryItem(name: "token", value: token)]) + + var request = makeRequest(for: endpoint, method: .post) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = Data((form.percentEncodedQuery ?? "").utf8) + + return try await session.data(for: request) + } + /// /// Delete the app password this ``Server`` is currently authenticating with, ending the account's session on the server side. /// diff --git a/Sources/Rainmaker/Serving.swift b/Sources/Rainmaker/Serving.swift index 4ed0b87..4a0d55d 100644 --- a/Sources/Rainmaker/Serving.swift +++ b/Sources/Rainmaker/Serving.swift @@ -96,6 +96,11 @@ protocol Serving: Sendable { /// func navigation() async throws -> [NavigationItem] + /// + /// Fetch the identifier and the display name of the account this server authenticates as. + /// + func currentUser() async throws -> User + /// /// List the notifications currently queued for the authenticated user. /// @@ -136,11 +141,111 @@ protocol Serving: Sendable { /// func notes(changedSince: Date) async throws -> NoteChanges + /// + /// List the notes of the authenticated user which changed since a given moment, unless the answer would be the same as the one a given entity tag was taken from. + /// + func notes(changedSince: Date, ifChangedFrom entityTag: String) async throws -> NoteChanges? + + /// + /// Retrieve one chunk of the notes of the authenticated user which changed since a given moment, either the first one of a pass or the one following a given cursor. + /// + func notes(changedSince: Date, chunkSize: Int, continuingAfter cursor: String?) async throws -> NoteChanges + + /// + /// Retrieve the first chunk of the notes of the authenticated user which changed since a given moment, unless the answer would be the same as the one a given entity tag was taken from. + /// + func notes(changedSince: Date, chunkSize: Int, ifChangedFrom entityTag: String) async throws -> NoteChanges? + + /// + /// Retrieve every chunk of one pass over the notes of the authenticated user which changed since a given moment, in order, as a stream. + /// + func noteChunks(changedSince: Date, chunkSize: Int) -> AsyncThrowingStream + + /// + /// List the notes of the authenticated user which changed since a given moment without their text, together with the identifiers of those which did not. + /// + func noteSummaries(changedSince: Date) async throws -> NoteSummaryChanges + + /// + /// List the notes of the authenticated user which changed since a given moment without their text, unless the answer would be the same as the one a given entity tag was taken from. + /// + func noteSummaries(changedSince: Date, ifChangedFrom entityTag: String) async throws -> NoteSummaryChanges? + + /// + /// Retrieve one chunk of the notes of the authenticated user which changed since a given moment without their text, either the first one of a pass or the one following a given cursor. + /// + func noteSummaries(changedSince: Date, chunkSize: Int, continuingAfter cursor: String?) async throws -> NoteSummaryChanges + + /// + /// Retrieve the first chunk of the notes of the authenticated user which changed since a given moment without their text, unless the answer would be the same as the one a given entity tag was taken from. + /// + func noteSummaries(changedSince: Date, chunkSize: Int, ifChangedFrom entityTag: String) async throws -> NoteSummaryChanges? + + /// + /// Retrieve every chunk of one pass over the notes of the authenticated user which changed since a given moment without their text, in order, as a stream. + /// + func noteSummaryChunks(changedSince: Date, chunkSize: Int) -> AsyncThrowingStream + + /// + /// Retrieve a single note of the authenticated user by its identifier. + /// + func note(_ id: Int) async throws -> Note + + /// + /// Retrieve a single note of the authenticated user by its identifier, unless it is still the one a given entity tag was taken from. + /// + func note(_ id: Int, ifChangedFrom entityTag: String) async throws -> Note? + + /// + /// Create a note for the authenticated user and return it as the server stored it. + /// + func createNote(title: String, category: String, content: String, modification: Date?, isFavorite: Bool) async throws -> Note + + /// + /// Change a note of the authenticated user and return it as the server stored it, optionally only if it is still the one a given entity tag was taken from. + /// + func updateNote(_ id: Int, title: String?, category: String?, content: String?, modification: Date?, isFavorite: Bool?, ifMatching entityTag: String?) async throws -> Note + + /// + /// Delete a note of the authenticated user. + /// + func deleteNote(_ id: Int) async throws + + /// + /// Retrieve a file a note refers to, such as an image embedded into it, into memory. + /// + func attachment(at path: String, ofNote noteId: Int) async throws -> NoteAttachment + + /// + /// Retrieve a file a note refers to, such as an image embedded into it, into a local file. + /// + func downloadAttachment(at path: String, ofNote noteId: Int, to destination: URL, force: Bool) async throws -> NoteAttachmentFile + + /// + /// Attach a local file to a note of the authenticated user and return the path the server stored it at. + /// + func addAttachment(_ source: URL, toNote noteId: Int, fileName: String?) async throws -> String + + /// + /// Attach the given bytes as a file to a note of the authenticated user and return the path the server stored it at. + /// + func addAttachment(_ data: Data, toNote noteId: Int, fileName: String) async throws -> String + + /// + /// Delete a file attached to a note of the authenticated user. + /// + func deleteAttachment(at path: String, ofNote noteId: Int) async throws + /// /// Look up the settings the notes app keeps for the authenticated user. /// func notesSettings() async throws -> NotesSettings + /// + /// Change the settings the notes app keeps for the authenticated user and return them as the server stored them. + /// + func updateNotesSettings(notesPath: String?, fileSuffix: String?, noteMode: NoteMode?, showsHiddenFiles: Bool?, loadsRecentNoteOnStartUp: Bool?) async throws -> NotesSettings + /// /// Retrieve one page of the activity stream the server records for the authenticated user. /// @@ -157,12 +262,17 @@ protocol Serving: Sendable { func events(_ options: ServerEventOptions) -> AsyncThrowingStream /// - /// Look up the login flow information. + /// Begin a login flow, which yields an app password once the user granted access in a browser. /// func login() async throws -> LoginFlow /// - /// Poll the status of a login flow. + /// Check once whether the user completed a login flow, returning `nil` while it is pending. + /// + func poll(_ flow: LoginFlow) async throws -> LoginResult? + + /// + /// Poll the status of a login flow, throwing while it is pending, which is deprecated on ``Server`` in favour of ``poll(_:)`` and kept here undeprecated so that the tests of its unchanged behaviour compile without warnings. /// func poll(_ endpoint: URL, token: String) async throws -> LoginResult diff --git a/Sources/RainmakerCLI/Commands/CurrentUser.swift b/Sources/RainmakerCLI/Commands/CurrentUser.swift new file mode 100644 index 0000000..83130ad --- /dev/null +++ b/Sources/RainmakerCLI/Commands/CurrentUser.swift @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +/// +/// Current user lookup command, which calls `Server.currentUser()`. +/// +/// The plain output is the identifier and the display name separated by a tab, so that a script can cut the identifier, which can differ from the login name given with `--user`, for example to pass it on to commands addressing the account. +/// +struct CurrentUser: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Show the identifier and the display name of the authenticated account. Requires authentication.") + + /// + /// The credentials of the account to look up. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// Whether to print the account as plain text or as JSON. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + func run() async throws { + guard let address = URL(string: unauthenticatedArguments.hostValue) else { + throw RainmakerCommandError.invalidAddress + } + + let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) + let user = try await server.currentUser() + + switch formatArguments.outputFormat { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(user) + + guard let json = String(data: data, encoding: .utf8) else { + throw RainmakerCommandError.encodingError + } + + print(json) + case .plain: + print("\(user.id)\t\(user.displayName)") + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes.swift b/Sources/RainmakerCLI/Commands/Notes.swift deleted file mode 100644 index b21b37f..0000000 --- a/Sources/RainmakerCLI/Commands/Notes.swift +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Iva Horn -// SPDX-License-Identifier: MIT - -import ArgumentParser -import Foundation -import Rainmaker - -/// -/// Notes retrieval command. -/// -struct Notes: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "List the notes of the authenticated user. Requires authentication and the server's notes app.") - - @OptionGroup - var authenticatedArguments: AuthenticatedArguments - - @OptionGroup - var formatArguments: FormatArguments - - @OptionGroup - var unauthenticatedArguments: UnauthenticatedArguments - - @Option(help: "List only the notes modified at or after this moment, given as whole seconds since the Unix epoch. Notes which did not change are reported by their identifier alone.") - var changedSince: Int? - - func run() async throws { - guard let address = URL(string: unauthenticatedArguments.hostValue) else { - throw RainmakerCommandError.invalidAddress - } - - let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) - - guard let changedSince else { - let notes = try await server.notes() - - switch formatArguments.outputFormat { - case .json: - let json = try encoded(notes) - print(json) - case .plain: - for note in notes { - print(note.title) - } - } - - return - } - - let changes = try await server.notes(changedSince: Date(timeIntervalSince1970: TimeInterval(changedSince))) - - switch formatArguments.outputFormat { - case .json: - let json = try encoded(changes) - print(json) - case .plain: - for note in changes.changed { - print(note.title) - } - - for id in changes.unchanged { - print("#\(id) (unchanged)") - } - } - } - - /// - /// Encode a retrieval result the way every JSON emitting subcommand does. - /// - private func encoded(_ value: some Encodable) throws -> String { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - - let data = try encoder.encode(value) - - guard let json = String(data: data, encoding: .utf8) else { - throw RainmakerCommandError.encodingError - } - - return json - } -} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Add.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Add.swift new file mode 100644 index 0000000..c6cb144 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Add.swift @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes.Attachment { + /// + /// Attachment addition subcommand, which calls `Server.addAttachment(_:toNote:fileName:)` with a local file. + /// + /// It prints the path the server stored the file at, which is what `notes attachment get` and `notes attachment delete` take and what the note's content can reference. + /// + struct Add: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Attach a local file to a note of the authenticated user and print the path the server stored it at.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the note to attach the file to. + /// + @Argument(help: "The identifier of the note to attach the file to.") + var noteId: Int + + /// + /// The path of the local file to attach, which may start with `~`. + /// + @Argument(help: "The local file to attach.") + var file: String + + /// + /// The name to store the file under, if it differs from that of ``file``. + /// + @Option(help: "The name to store the file under. Defaults to the name of the local file.") + var name: String? + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + + // `URL(fileURLWithPath:)` does not expand `~`; do it ourselves so paths like `~/Desktop/photo.png` resolve to the user's home directory instead of a literal `~` folder. + let source = URL(fileURLWithPath: (file as NSString).expandingTildeInPath) + let path = try await server.addAttachment(source, toNote: noteId, fileName: name) + + print(path) + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Delete.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Delete.swift new file mode 100644 index 0000000..d891b64 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Delete.swift @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes.Attachment { + /// + /// Attachment deletion subcommand, which calls `Server.deleteAttachment(at:ofNote:)`. + /// + /// Like `notes delete` it prints nothing on success. Releases of the notes app before 6.1.0 do not offer the deletion of attachments and make it fail. + /// + struct Delete: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Delete a file attached to a note of the authenticated user. Requires the notes app 6.1.0 or newer.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the note the attachment belongs to. + /// + @Argument(help: "The identifier of the note the attachment belongs to.") + var noteId: Int + + /// + /// The path of the attachment as `notes attachment add` printed it. + /// + @Argument(help: "The path of the attachment as `notes attachment add` printed it.") + var path: String + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + try await server.deleteAttachment(at: path, ofNote: noteId) + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Get.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Get.swift new file mode 100644 index 0000000..237f1ec --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment+Get.swift @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes.Attachment { + /// + /// Attachment retrieval subcommand, which calls `Server.downloadAttachment(at:ofNote:to:force:)` when given a file to write to and `Server.attachment(at:ofNote:)` otherwise. + /// + /// Like `conversations avatar` it has no ``FormatArguments``, because the payload is a file, and the only two things worth doing with it on a command line are writing it somewhere and reporting what it is. Writing streams the file to disk rather than holding it in memory. + /// + struct Get: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Retrieve a file attached to a note of the authenticated user.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the note the path is relative to. + /// + @Argument(help: "The identifier of the note the path is relative to.") + var noteId: Int + + /// + /// The path of the file relative to the folder of the note's category, as `notes attachment add` printed it. + /// + @Argument(help: "The path of the file relative to the folder of the note's category, as `notes attachment add` printed it.") + var path: String + + /// + /// The local file to write the attachment to, if any. + /// + @Option(help: "Local file to write the attachment to, which must not be a directory. Without it, only the type and size of the attachment are reported.") + var output: String? + + /// + /// Whether an existing file at ``output`` is replaced. + /// + @Flag(help: "Replace the file given as --output when it exists; a directory is never replaced.") + var force: Bool = false + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + + guard let output else { + let attachment = try await server.attachment(at: path, ofNote: noteId) + print("\(attachment.contentType)\t\(attachment.data.count)") + return + } + + // `URL(fileURLWithPath:)` does not expand `~`; do it ourselves so paths like `~/Desktop/photo.png` resolve to the user's home directory instead of a literal `~` folder. + let expandedOutput = (output as NSString).expandingTildeInPath + let file = try await server.downloadAttachment(at: path, ofNote: noteId, to: URL(fileURLWithPath: expandedOutput), force: force) + + print("\(file.contentType)\t\(expandedOutput)") + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment.swift new file mode 100644 index 0000000..54c71ee --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Attachment.swift @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser + +extension Notes { + /// + /// Attachment command grouping the retrieval, the addition and the deletion of the files attached to a note. + /// + /// This is a group of its own within ``Notes`` because every subcommand addresses a note and a path relative to the folder of its category, which is what `Server.addAttachment(_:toNote:fileName:)` returns and the other two take. Each subcommand is declared in a file of its own as an extension of this type, such as ``Notes/Attachment/Get`` in `Notes+Attachment+Get.swift`. + /// + struct Attachment: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Retrieve, add and delete the files attached to a note of the authenticated user.", + subcommands: [Get.self, Add.self, Delete.self] + ) + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Create.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Create.swift new file mode 100644 index 0000000..614c6c0 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Create.swift @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes { + /// + /// Note creation subcommand, which calls `Server.createNote(title:category:content:modification:isFavorite:)`. + /// + /// It prints the note the server created as ``Notes/printSummary(of:as:)`` describes, because the server assigns the identifier and may change the requested title. + /// + struct Create: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Create a note for the authenticated user and print its identifier, entity tag and title.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The form to print the result in. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The requested title, which the server sanitizes. + /// + @Option(help: "The title of the new note. The server removes characters which cannot be part of a file name and numbers a title already taken.") + var title: String + + /// + /// The category to file the note under. + /// + @Option(help: "The category to file the new note under, with '/' delimiting sub-categories.") + var category: String = "" + + /// + /// The text of the new note given inline. + /// + @Option(help: "The text of the new note.") + var content: String? + + /// + /// The path of a local file holding the text of the new note. + /// + @Option(help: "A local file holding the text of the new note, as an alternative to --content.") + var contentFile: String? + + /// + /// The moment to stamp the new note with, in whole seconds since the Unix epoch. + /// + @Option(help: "The modification moment of the new note, given as whole seconds since the Unix epoch. Defaults to the moment the server writes it.") + var modified: Int? + + /// + /// Whether the new note is marked as a favorite. + /// + @Flag(help: "Mark the new note as a favorite.") + var favorite: Bool = false + + func validate() throws { + if content != nil, contentFile != nil { + throw ValidationError("--content and --content-file cannot be combined.") + } + } + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + let text = try Notes.content(content, orContentsOf: contentFile) ?? "" + let modification = modified.map { Date(timeIntervalSince1970: TimeInterval($0)) } + let note = try await server.createNote(title: title, category: category, content: text, modification: modification, isFavorite: favorite) + + try Notes.printSummary(of: note, as: formatArguments.outputFormat) + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Delete.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Delete.swift new file mode 100644 index 0000000..3ad43a1 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Delete.swift @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes { + /// + /// Note deletion subcommand, which calls `Server.deleteNote(_:)`. + /// + /// Like the top-level `delete` command it prints nothing on success. + /// + struct Delete: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Delete a note of the authenticated user.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the note to delete. + /// + @Argument(help: "The identifier of the note to delete.") + var id: Int + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + try await server.deleteNote(id) + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Get.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Get.swift new file mode 100644 index 0000000..af368f4 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Get.swift @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes { + /// + /// Single note retrieval subcommand, which calls `Server.note(_:)`, or `Server.note(_:ifChangedFrom:)` when given an entity tag. + /// + /// The plain output is the content of the note alone, so it can be piped elsewhere as the text it is, while its entity tag, which `--if-none-match` takes, goes to standard error as an `#etag` line. The JSON output carries every other value as well. + /// + struct Get: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Retrieve a single note of the authenticated user by its identifier.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The form to print the result in. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the note to retrieve. + /// + @Argument(help: "The identifier of the note to retrieve.") + var id: Int + + /// + /// The entity tag of the copy at hand, which makes the retrieval conditional. + /// + @Option(help: "Print 'Not modified.' instead of the note while it still carries this entity tag, which a previous retrieval printed to standard error as its '#etag' line.") + var ifNoneMatch: String? + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + let note: Note? = if let ifNoneMatch { + try await server.note(id, ifChangedFrom: ifNoneMatch) + } else { + try await server.note(id) + } + + guard let note else { + switch formatArguments.outputFormat { + case .json: + print("null") + case .plain: + print("Not modified.") + } + + return + } + + switch formatArguments.outputFormat { + case .json: + try print(Notes.encoded(note)) + case .plain: + // The entity tag goes to standard error, so that standard output stays the text of the note alone. + FileHandle.standardError.write(Data("#etag \(note.entityTag)\n".utf8)) + + // The content is printed as it is, without a line break the note itself does not end with. + print(note.content, terminator: "") + } + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+List.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+List.swift new file mode 100644 index 0000000..cd3190d --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+List.swift @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes { + /// + /// Notes listing subcommand, which is the default subcommand of ``Notes``. + /// + /// Without options it lists every note through `Server.notes()`. `--changed-since` lists the notes changed since a moment through `Server.notes(changedSince:)`, `--chunk-size` and `--cursor` retrieve one chunk of such a listing through `Server.notes(changedSince:chunkSize:continuingAfter:)`, and `--if-none-match` makes the listing, or the first chunk, conditional through `Server.notes(changedSince:ifChangedFrom:)` or `Server.notes(changedSince:chunkSize:ifChangedFrom:)`. + /// `--summaries` leaves out the text of every note by going through the `Server.noteSummaries` counterpart of each of those calls instead, which always lists changes since a moment, so without `--changed-since` it lists every note as changed since the Unix epoch. + /// The plain output of a listing of changes ends with `#cursor`, `#etag` and `#last-modified` lines, so that the values `--cursor`, `--if-none-match` and `--changed-since` take are at hand without the JSON output. + /// + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "List the notes of the authenticated user, all of them, those changed since a moment, or one chunk of those.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The form to print the result in. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The moment to list changes since, in whole seconds since the Unix epoch, which is the moment the previous listing reported as `lastModified`. + /// + @Option(help: "List only the notes changed at or after this moment, given as whole seconds since the Unix epoch, such as the '#last-modified' line of a previous listing. Notes which did not change are reported by their identifier alone. Defaults to the Unix epoch when another option needs a moment.") + var changedSince: Int? + + /// + /// The number of notes to send in full at most, which makes the listing chunked. + /// + @Option(help: "Retrieve one chunk of at most this many changed notes. A chunk which is not the last one prints a '#cursor' line to continue with.") + var chunkSize: Int? + + /// + /// The cursor of the previous chunk of the same pass. + /// + @Option(help: "Continue a chunked listing after the chunk this cursor was printed for. Requires --chunk-size and the same --changed-since.") + var cursor: String? + + /// + /// The entity tag of the previous listing, which makes the listing conditional. + /// + @Option(help: "Print 'Not modified.' instead of a listing when the server's answer would carry this entity tag of a previous listing, as its '#etag' line printed it.") + var ifNoneMatch: String? + + /// + /// Whether to list the notes without their text, through the `Server.noteSummaries` calls rather than the `Server.notes` calls. + /// + @Flag(help: "Leave out the text of every note, so that it is not downloaded. The listing is then always one of changes since a moment, which defaults to the Unix epoch, and its entity tag only matches later listings with this flag.") + var summaries = false + + func validate() throws { + if cursor != nil, chunkSize == nil { + throw ValidationError("--cursor requires --chunk-size.") + } + + if cursor != nil, ifNoneMatch != nil { + throw ValidationError("--cursor and --if-none-match cannot be combined, because only the first chunk of a pass can be conditional.") + } + } + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + + guard changedSince != nil || chunkSize != nil || ifNoneMatch != nil || summaries else { + let notes = try await server.notes() + + switch formatArguments.outputFormat { + case .json: + try print(Notes.encoded(notes)) + case .plain: + for note in notes { + print(note.title) + } + } + + return + } + + let moment = Date(timeIntervalSince1970: TimeInterval(changedSince ?? 0)) + + guard summaries == false else { + try await listSummaries(on: server, changedSince: moment) + return + } + + let changes: NoteChanges? = switch (chunkSize, ifNoneMatch) { + case let (chunkSize?, ifNoneMatch?): + try await server.notes(changedSince: moment, chunkSize: chunkSize, ifChangedFrom: ifNoneMatch) + case let (chunkSize?, nil): + try await server.notes(changedSince: moment, chunkSize: chunkSize, continuingAfter: cursor) + case let (nil, ifNoneMatch?): + try await server.notes(changedSince: moment, ifChangedFrom: ifNoneMatch) + case (nil, nil): + try await server.notes(changedSince: moment) + } + + guard let changes else { + printNotModified() + return + } + + switch formatArguments.outputFormat { + case .json: + try print(Notes.encoded(changes)) + case .plain: + printPlain(titles: changes.changed.map(\.title), unchanged: changes.unchanged, chunkCursor: changes.chunkCursor, entityTag: changes.entityTag, lastModified: changes.lastModified) + } + } + + /// + /// List the summaries of the notes changed since a moment, which is what `--summaries` does in place of the rest of ``run()``. + /// + /// It chooses among the `Server.noteSummaries` calls by the same options and prints their result in the same forms as ``run()`` prints the result of the corresponding `Server.notes` call. + /// + /// - Parameters: + /// - server: The server to list the summaries on. + /// - moment: The moment to list changes since. + /// + private func listSummaries(on server: Server, changedSince moment: Date) async throws { + let changes: NoteSummaryChanges? = switch (chunkSize, ifNoneMatch) { + case let (chunkSize?, ifNoneMatch?): + try await server.noteSummaries(changedSince: moment, chunkSize: chunkSize, ifChangedFrom: ifNoneMatch) + case let (chunkSize?, nil): + try await server.noteSummaries(changedSince: moment, chunkSize: chunkSize, continuingAfter: cursor) + case let (nil, ifNoneMatch?): + try await server.noteSummaries(changedSince: moment, ifChangedFrom: ifNoneMatch) + case (nil, nil): + try await server.noteSummaries(changedSince: moment) + } + + guard let changes else { + printNotModified() + return + } + + switch formatArguments.outputFormat { + case .json: + try print(Notes.encoded(changes)) + case .plain: + printPlain(titles: changes.changed.map(\.title), unchanged: changes.unchanged, chunkCursor: changes.chunkCursor, entityTag: changes.entityTag, lastModified: changes.lastModified) + } + } + + /// + /// Print that the server answered a conditional listing with not modified, in the form ``formatArguments`` asks for. + /// + private func printNotModified() { + switch formatArguments.outputFormat { + case .json: + print("null") + case .plain: + print("Not modified.") + } + } + + /// + /// Print a listing of changes in the plain form, which is the same for notes with and without their text. + /// + /// - Parameters: + /// - titles: The titles of the notes sent in full, one per line. + /// - unchanged: The identifiers of the notes sent as identifiers alone, one per line. + /// - chunkCursor: The cursor to continue a chunked listing with, printed as a `#cursor` line when there is one. + /// - entityTag: The entity tag of the response, printed as an `#etag` line when there is one, which is what `--if-none-match` takes. + /// - lastModified: The moment the server says to continue from, printed as a `#last-modified` line in whole seconds since the Unix epoch when there is one, which is what `--changed-since` takes. + /// + private func printPlain(titles: [String], unchanged: [Int], chunkCursor: String?, entityTag: String?, lastModified: Date?) { + for title in titles { + print(title) + } + + for id in unchanged { + print("#\(id) (unchanged)") + } + + // The cursor is what continues the pass, so it is surfaced whenever there is more to come. + if let chunkCursor { + print("#cursor \(chunkCursor)") + } + + // Both values are what the next listing is based on, which the plain output would otherwise only offer through the JSON output. + if let entityTag { + print("#etag \(entityTag)") + } + + if let lastModified { + print("#last-modified \(Int(lastModified.timeIntervalSince1970.rounded(.down)))") + } + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes+Update.swift b/Sources/RainmakerCLI/Commands/Notes/Notes+Update.swift new file mode 100644 index 0000000..1eaa316 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes+Update.swift @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +extension Notes { + /// + /// Note change subcommand, which calls `Server.updateNote(_:title:category:content:modification:isFavorite:ifMatching:)`. + /// + /// Only the options given are sent, so everything else of the note stays as it is. It prints the note as the server stored it as ``Notes/printSummary(of:as:)`` describes, including the new entity tag to base the next change on. + /// + struct Update: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Change a note of the authenticated user and print its identifier, entity tag and title.") + + /// + /// The credentials, which every notes feature requires. + /// + @OptionGroup + var authenticatedArguments: AuthenticatedArguments + + /// + /// The form to print the result in. + /// + @OptionGroup + var formatArguments: FormatArguments + + /// + /// The address of the server. + /// + @OptionGroup + var unauthenticatedArguments: UnauthenticatedArguments + + /// + /// The identifier of the note to change. + /// + @Argument(help: "The identifier of the note to change.") + var id: Int + + /// + /// The new title, which the server sanitizes. + /// + @Option(help: "The new title, which renames the note's file. The note keeps its identifier.") + var title: String? + + /// + /// The new category. + /// + @Option(help: "The new category, with '/' delimiting sub-categories and an empty string for none.") + var category: String? + + /// + /// The new text given inline. + /// + @Option(help: "The new text of the note.") + var content: String? + + /// + /// The path of a local file holding the new text. + /// + @Option(help: "A local file holding the new text of the note, as an alternative to --content.") + var contentFile: String? + + /// + /// The moment to stamp the note with, in whole seconds since the Unix epoch. + /// + @Option(help: "The modification moment to stamp the note with, given as whole seconds since the Unix epoch.") + var modified: Int? + + /// + /// Whether the note is to be marked as a favorite. + /// + @Option(help: "Whether the note is marked as a favorite, 'true' or 'false'.") + var favorite: Bool? + + /// + /// The entity tag of the copy the change is based on, which makes the change conditional. + /// + @Option(help: "Change the note only while it still carries this entity tag. Otherwise nothing is changed and the command fails.") + var ifMatch: String? + + func validate() throws { + if content != nil, contentFile != nil { + throw ValidationError("--content and --content-file cannot be combined.") + } + } + + func run() async throws { + let server = try Notes.makeServer(authenticatedArguments: authenticatedArguments, unauthenticatedArguments: unauthenticatedArguments) + let text = try Notes.content(content, orContentsOf: contentFile) + let modification = modified.map { Date(timeIntervalSince1970: TimeInterval($0)) } + let note = try await server.updateNote(id, title: title, category: category, content: text, modification: modification, isFavorite: favorite, ifMatching: ifMatch) + + try Notes.printSummary(of: note, as: formatArguments.outputFormat) + } + } +} diff --git a/Sources/RainmakerCLI/Commands/Notes/Notes.swift b/Sources/RainmakerCLI/Commands/Notes/Notes.swift new file mode 100644 index 0000000..14c0b40 --- /dev/null +++ b/Sources/RainmakerCLI/Commands/Notes/Notes.swift @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Foundation +import Rainmaker + +/// +/// Notes command grouping the listing, the retrieval, the creation, the change and the deletion of notes, as well as the ``Notes/Attachment`` group for the files attached to them. +/// +/// This is a group because every subcommand reads or writes the same app through the same credentials, mirroring how ``Conversations`` groups its subcommands. Each subcommand is declared in a file of its own as an extension of this type, such as ``Notes/List`` in `Notes+List.swift`. +/// ``Notes/List`` is the default subcommand, so `notes` and `notes --changed-since ` keep listing notes as they did before this became a group. +/// +/// This type shadows the library's `Notes` capability within this module, which the subcommands therefore do not refer to. Spelling that capability `Rainmaker.Notes` would not help either, because the root command of this module is called `Rainmaker` and shadows the library's module name in turn. +/// +struct Notes: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List, retrieve, create, change and delete the notes of the authenticated user and their attachments. Requires authentication and the server's notes app.", + subcommands: [List.self, Get.self, Create.self, Update.self, Delete.self, Attachment.self], + defaultSubcommand: List.self + ) + + /// + /// Build the server every subcommand talks to from the arguments it was given. + /// + /// - Parameters: + /// - authenticatedArguments: The credentials, which every notes feature requires. + /// - unauthenticatedArguments: The address of the server. + /// + static func makeServer(authenticatedArguments: AuthenticatedArguments, unauthenticatedArguments: UnauthenticatedArguments) throws -> Server { + guard let address = URL(string: unauthenticatedArguments.hostValue) else { + throw RainmakerCommandError.invalidAddress + } + + return Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) + } + + /// + /// Encode a result the way every JSON emitting subcommand does. + /// + /// - Parameters: + /// - value: The result to encode. + /// + static func encoded(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + let data = try encoder.encode(value) + + guard let json = String(data: data, encoding: .utf8) else { + throw RainmakerCommandError.encodingError + } + + return json + } + + /// + /// Print a single note as ``Notes/Create`` and ``Notes/Update`` do. + /// + /// The plain form is the identifier, the entity tag and the title separated by tabs, because the identifier is what every further subcommand addresses the note by, the entity tag is what `notes update --if-match` and `notes get --if-none-match` take, and the title is what the server made of the requested one. + /// + /// - Parameters: + /// - note: The note to print. + /// - outputFormat: The form to print it in. + /// + static func printSummary(of note: Note, as outputFormat: OutputFormat) throws { + switch outputFormat { + case .json: + try print(encoded(note)) + case .plain: + print("\(note.id)\t\(note.entityTag)\t\(note.title)") + } + } + + /// + /// Read the content a subcommand was given either inline or as a file. + /// + /// - Parameters: + /// - content: The content given inline, if any. + /// - contentFile: The path of a local file holding the content, if any, which may start with `~`. + /// + /// - Returns: The content, or `nil` when neither was given. + /// + static func content(_ content: String?, orContentsOf contentFile: String?) throws -> String? { + guard let contentFile else { + return content + } + + // `URL(fileURLWithPath:)` does not expand `~`; do it ourselves so paths like `~/Documents/Note.md` resolve to the user's home directory instead of a literal `~` folder. + let expandedPath = (contentFile as NSString).expandingTildeInPath + + return try String(contentsOf: URL(fileURLWithPath: expandedPath), encoding: .utf8) + } +} diff --git a/Sources/RainmakerCLI/Commands/NotesSettings.swift b/Sources/RainmakerCLI/Commands/NotesSettings.swift index 06a2fbf..97d6e90 100644 --- a/Sources/RainmakerCLI/Commands/NotesSettings.swift +++ b/Sources/RainmakerCLI/Commands/NotesSettings.swift @@ -6,27 +6,80 @@ import Foundation import Rainmaker /// -/// Notes app settings lookup command. +/// Notes app settings command, which looks the settings up through `Server.notesSettings()` or, when any setting is given, changes them through `Server.updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`. +/// +/// Either way it prints the settings as the server has them afterwards, which after a change includes the values the server sanitized. This type shadows the library's settings model of the same name within this module, which is why the settings are only ever referred to through type inference here. /// struct NotesSettings: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "Show where the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app.") + static let configuration = CommandConfiguration(abstract: "Show or change where and how the notes app stores the notes of the authenticated user. Requires authentication and the server's notes app.") + /// + /// The credentials, which every notes feature requires. + /// @OptionGroup var authenticatedArguments: AuthenticatedArguments + /// + /// The form to print the settings in. + /// @OptionGroup var formatArguments: FormatArguments + /// + /// The address of the server. + /// @OptionGroup var unauthenticatedArguments: UnauthenticatedArguments + /// + /// The new path of the folder the notes are stored in. + /// + @Option(help: "Store the notes in this folder, relative to the account's files, with an empty string for the root folder. Existing notes are not moved.") + var notesPath: String? + + /// + /// The new file extension for notes created from now on. + /// + @Option(help: "Give the notes created from now on this file extension, such as '.md' or '.txt'.") + var fileSuffix: String? + + /// + /// The new way the web interface presents a note. + /// + @Option(help: "Open notes in the web interface in this mode.") + var noteMode: NoteMode? + + /// + /// Whether the notes app is to list hidden files and folders. + /// + @Option(help: "Whether files and folders whose names start with a dot are listed as notes and categories, 'true' or 'false'. Requires notes app 6.1.0 or newer.") + var showHiddenFiles: Bool? + + /// + /// Whether the web interface is to open the most recent note when it starts. + /// + @Option(help: "Whether the web interface opens the most recently edited note when it starts, 'true' or 'false'. Requires notes app 6.1.0 or newer.") + var loadRecentNoteOnStartUp: Bool? + + /// + /// Whether any setting to change was given, which is what makes this command change the settings rather than only show them. + /// + var changesSettings: Bool { + notesPath != nil || fileSuffix != nil || noteMode != nil || showHiddenFiles != nil || loadRecentNoteOnStartUp != nil + } + func run() async throws { guard let address = URL(string: unauthenticatedArguments.hostValue) else { throw RainmakerCommandError.invalidAddress } let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) - let settings = try await server.notesSettings() + + let settings = if changesSettings { + try await server.updateNotesSettings(notesPath: notesPath, fileSuffix: fileSuffix, noteMode: noteMode, showsHiddenFiles: showHiddenFiles, loadsRecentNoteOnStartUp: loadRecentNoteOnStartUp) + } else { + try await server.notesSettings() + } switch formatArguments.outputFormat { case .json: diff --git a/Sources/RainmakerCLI/Commands/Poll.swift b/Sources/RainmakerCLI/Commands/Poll.swift index aef0519..ed17b44 100644 --- a/Sources/RainmakerCLI/Commands/Poll.swift +++ b/Sources/RainmakerCLI/Commands/Poll.swift @@ -30,6 +30,9 @@ struct Poll: AsyncParsableCommand { let server = Server(address: address) + // Polling uses only the endpoint and the token of a flow, so the endpoint stands in for the login page this command is not given. + let flow = LoginFlow(endpoint: address, entry: address, token: token) + var currentTry = 0 var loginResult: LoginResult? @@ -40,17 +43,15 @@ struct Poll: AsyncParsableCommand { print("Polling (try \(currentTry) out of \(tries))...") } - do { - loginResult = try await server.poll(address, token: token) + // A pending flow is nil, while every real failure, such as an unreachable server or an endpoint which does not exist, ends the command with its error. + loginResult = try await server.poll(flow) + if loginResult != nil { if formatArguments.outputFormat == .plain { print("Received result.") } break - } catch RainmakerError.responseDecodingFailed { - // The server may respond with an empty array ("[]") while the flow is incomplete. - // This is also expected and can be ignored to continue polling. } try await Task.sleep(nanoseconds: 1_000_000_000) diff --git a/Sources/RainmakerCLI/Commands/Rainmaker.swift b/Sources/RainmakerCLI/Commands/Rainmaker.swift index 1ad5bce..502bd75 100644 --- a/Sources/RainmakerCLI/Commands/Rainmaker.swift +++ b/Sources/RainmakerCLI/Commands/Rainmaker.swift @@ -10,7 +10,7 @@ import Foundation @main struct Rainmaker: AsyncParsableCommand { static let configuration: CommandConfiguration = { - var subcommands: [any ParsableCommand.Type] = [Activities.self, ActivityFilters.self, Capabilities.self, Collectives.self, Conversations.self, CreateDirectory.self, Delete.self, DeleteAppPassword.self, Download.self, Info.self, List.self, Login.self, Move.self, Navigation.self, Notes.self, NotesSettings.self, Notifications.self, Poll.self, Trash.self, Upload.self, Watch.self] + var subcommands: [any ParsableCommand.Type] = [Activities.self, ActivityFilters.self, Capabilities.self, Collectives.self, Conversations.self, CreateDirectory.self, CurrentUser.self, Delete.self, DeleteAppPassword.self, Download.self, Info.self, List.self, Login.self, Move.self, Navigation.self, Notes.self, NotesSettings.self, Notifications.self, Poll.self, Trash.self, Upload.self, Watch.self] // The fixture recorder controls Docker and is therefore available on macOS only. #if os(macOS) diff --git a/Sources/RainmakerCLI/Commands/Watch.swift b/Sources/RainmakerCLI/Commands/Watch.swift index 60c3813..552b26c 100644 --- a/Sources/RainmakerCLI/Commands/Watch.swift +++ b/Sources/RainmakerCLI/Commands/Watch.swift @@ -9,9 +9,10 @@ import Rainmaker /// Observe server-side changes and print each event as it arrives. /// /// This exercises ``Rainmaker/Server/events(_:)``: it prefers the `notify_push` WebSocket when the server offers it and otherwise polls, printing one line per ``Rainmaker/ServerEvent``. It runs until interrupted. +/// The `--transport` option sets `ServerEventOptions.transport` of the library, so `--transport polling` only polls and never opens a WebSocket. /// struct Watch: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "Observe server-side changes over notify_push (or polling when unavailable) and print each event. Runs until interrupted.") + static let configuration = CommandConfiguration(abstract: "Observe server-side changes over notify_push (or polling when unavailable or asked to) and print each event. Runs until interrupted.") @OptionGroup var authenticatedArguments: AuthenticatedArguments @@ -19,9 +20,12 @@ struct Watch: AsyncParsableCommand { @OptionGroup var unauthenticatedArguments: UnauthenticatedArguments - @Option(help: "The polling interval in seconds used when notify_push is unavailable.") + @Option(help: "The polling interval in seconds used when notify_push is unavailable or not used.") var pollInterval: Double = 30 + @Option(help: "How to learn about changes: 'automatic' prefers notify_push and polls when it is unavailable, 'polling' only polls.") + var transport: ServerEventTransport = .automatic + @Flag(name: .customLong("listen-file-ids"), help: "Request per-file identifiers via notify_file_id.") var listenFileIDs = false @@ -31,7 +35,7 @@ struct Watch: AsyncParsableCommand { } let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue) - let options = ServerEventOptions(pollInterval: pollInterval, listenFileIDs: listenFileIDs) + let options = ServerEventOptions(pollInterval: pollInterval, listenFileIDs: listenFileIDs, transport: transport) for try await event in server.events(options) { print(Self.describe(event)) diff --git a/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift b/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift index db83f0b..82f6c54 100644 --- a/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift +++ b/Sources/RainmakerCLI/Fixtures/FixtureOrchestrator.swift @@ -31,6 +31,7 @@ "CapabilitiesTests", "ConversationsTests", "CreateDirectoryTests", + "CurrentUserTests", "DeleteTests", "DownloadTests", "InfoTests", @@ -38,12 +39,14 @@ "LoginTests", "MoveTests", "NavigationTests", + "NoteAttachmentTests", + "NoteMutationTests", "NotesTests", "UploadTests", ] /// - /// The identifier of the notes app, which `NotesTests` cannot record anything without. + /// The identifier of the notes app, which `NotesTests`, `NoteMutationTests` and `NoteAttachmentTests` cannot record anything without. /// /// It is named on its own rather than only as part of ``installedApps`` because what follows its installation is a lookup specific to it, namely ``resolveNotesFolder(on:)``. /// diff --git a/Sources/RainmakerCLI/NoteMode+ExpressibleByArgument.swift b/Sources/RainmakerCLI/NoteMode+ExpressibleByArgument.swift new file mode 100644 index 0000000..8fda7b9 --- /dev/null +++ b/Sources/RainmakerCLI/NoteMode+ExpressibleByArgument.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Rainmaker + +/// +/// Makes the library's note mode directly usable as the value of the `--note-mode` option of the `notes-settings` command. +/// +/// The conformance lives here rather than in the library so that `Rainmaker` itself stays free of a dependency on ArgumentParser, as with ``ActivitySort``. Because ``NoteMode`` is backed by the string the server uses, ArgumentParser's default implementation for `RawRepresentable` covers everything, including the list of `rich`, `edit` and `preview` it offers as completions, which is why the extension is empty. +/// +extension NoteMode: ExpressibleByArgument {} diff --git a/Sources/RainmakerCLI/ServerEventTransport+ExpressibleByArgument.swift b/Sources/RainmakerCLI/ServerEventTransport+ExpressibleByArgument.swift new file mode 100644 index 0000000..51017d2 --- /dev/null +++ b/Sources/RainmakerCLI/ServerEventTransport+ExpressibleByArgument.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import ArgumentParser +import Rainmaker + +/// +/// Makes the library's event transport directly usable as the value of the `watch` command's `--transport` option. +/// +/// The conformance lives here rather than in the library so that `Rainmaker` itself stays free of a dependency on ArgumentParser, as with ``Rainmaker/ActivitySort``. Because ``Rainmaker/ServerEventTransport`` is backed by a string and lists its cases, ArgumentParser's default implementations cover everything, including the possible values shown in the help, which is why the extension is empty. +/// +extension ServerEventTransport: ExpressibleByArgument {} diff --git a/Tests/RainmakerTests/CapabilitiesTests.swift b/Tests/RainmakerTests/CapabilitiesTests.swift index f595bb0..9d3b967 100644 --- a/Tests/RainmakerTests/CapabilitiesTests.swift +++ b/Tests/RainmakerTests/CapabilitiesTests.swift @@ -70,8 +70,12 @@ import Testing #expect(notes.apiVersion?.isEmpty == false) #expect(notes.version?.isEmpty == false) #expect(notes.notesPath?.isEmpty == false) + #expect(notes.isSupported) #expect(capabilities.contains(Notes.self)) + // Whichever release the app store hands this server version, the attachment behaviours tied to it agree with each other, because both arrived with the same release. + #expect(notes.storesAttachmentsPerNote == notes.supportsAttachmentDeletion) + // Talk is likewise installed on the recording containers on demand. Its features are named flags rather than an API version, because it versions its endpoints in their paths instead. let talk = try #require(try capabilities.get(Talk.self)) #expect(talk.version?.isEmpty == false) diff --git a/Tests/RainmakerTests/CurrentUserRequestTests.swift b/Tests/RainmakerTests/CurrentUserRequestTests.swift new file mode 100644 index 0000000..ffb5246 --- /dev/null +++ b/Tests/RainmakerTests/CurrentUserRequestTests.swift @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About the request ``Server/currentUser()`` sends and how it maps the statuses and bodies a server answers with onto a ``User`` and ``RainmakerError``. +/// +/// These tests deliberately do not use the fixture tree: a live Nextcloud server cannot be made to reject the credentials of the recording account, to fail an OCS request with a success status or to answer with something other than JSON, and a replayed fixture proves neither the request headers nor the cancellation. The replayed counterpart is ``CurrentUserTests``. +/// +@Suite("Current User Requests") struct CurrentUserRequestTests { + /// + /// The address of the server the tests look up the current user on. + /// + let serverAddress = URL(string: "http://localhost/")! + + /// + /// A response body with the details of an account whose identifier differs from its login name, as a server sends it for an account backed by an LDAP directory, reduced to the fields around those ``CurrentUserResponse`` decodes. + /// + let payload = #"{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"enabled":true,"id":"5f2b9c1e-4a7d","lastLogin":1788879440000,"quota":{"free":-3,"used":0,"total":-3,"relative":0,"quota":-3},"email":"jane@example.com","displayname":"Jane Doe","display-name":"Jane Doe","groups":["admin"]}}}"# + + /// + /// Build a server authenticating with an email address as its login name, whose session is the given mock. + /// + private func makeServer(session: any Requesting) -> Server { + Server(address: serverAddress, password: "secret", user: "jane@example.com", session: session, userAgent: "RainmakerTests") + } + + @Test("Returns The Identifier Rather Than The Login Name") + func returnsIdentifier() async throws { + let session = MockRequesting(string: payload, headerFields: ["Content-Type": "application/json; charset=utf-8"]) + let user = try await makeServer(session: session).currentUser() + let request = try #require(session.requests.first) + let expectedAuthorization = "Basic \(Data("jane@example.com:secret".utf8).base64EncodedString())" + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "GET") + #expect(request.url?.path == "/ocs/v2.php/cloud/user") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "OCS-APIRequest") == "true") + #expect(request.value(forHTTPHeaderField: "Authorization") == expectedAuthorization) + #expect(user == User(id: "5f2b9c1e-4a7d", displayName: "Jane Doe")) + } + + @Test("Rejected Credentials Report The Status") + func rejectedCredentials() async throws { + let body = #"{"ocs":{"meta":{"status":"failure","statuscode":997,"message":"Current user is not logged in"},"data":[]}}"# + let session = MockRequesting(string: body, statusCode: 401) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 401)) { + _ = try await makeServer(session: session).currentUser() + } + } + + @Test("A Missing Route Reports The Status") + func missingRoute() async throws { + let session = MockRequesting(string: "", statusCode: 404, headerFields: ["Content-Type": "text/html"]) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 404)) { + _ = try await makeServer(session: session).currentUser() + } + } + + @Test("Something Other Than Account Details Fails To Decode") + func undecodableBody() async throws { + let session = MockRequesting(string: "", headerFields: ["Content-Type": "text/html"]) + + await #expect(throws: RainmakerError.responseDecodingFailed(reason: "The server did not answer with the details of the current user.")) { + _ = try await makeServer(session: session).currentUser() + } + } + + @Test("A Failed OCS Request Fails To Decode") + func failedOCSRequest() async throws { + let body = #"{"ocs":{"meta":{"status":"failure","statuscode":100,"message":"Broken"},"data":{"id":"admin","displayname":"admin"}}}"# + let session = MockRequesting(string: body) + + await #expect(throws: RainmakerError.responseDecodingFailed(reason: "OCS request failed (100): Broken")) { + _ = try await makeServer(session: session).currentUser() + } + } + + @Test("Cancelling The Task Cancels The Request") + func cancellation() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(string: payload)) + let server = makeServer(session: session) + + let lookup = Task { + try await server.currentUser() + } + + // The request runs within the calling task, so a Shortcuts action which is stopped ends the lookup at once. + #expect(try await eventually { session.suspendedCount == 1 }) + lookup.cancel() + + let result = await lookup.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + } +} diff --git a/Tests/RainmakerTests/CurrentUserTests.swift b/Tests/RainmakerTests/CurrentUserTests.swift new file mode 100644 index 0000000..2ac26ee --- /dev/null +++ b/Tests/RainmakerTests/CurrentUserTests.swift @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import RainmakerTestServerTags +import Testing + +/// +/// About ``Server/currentUser()`` against recorded responses of a live server. +/// +/// The statuses and bodies a live server cannot be made to answer with are covered by ``CurrentUserRequestTests`` instead. +/// +@Suite("Current User") struct CurrentUserTests: ServerTesting { + @Test("Require Credentials", arguments: ServerVersion.allCases) + func requireCredentials(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion) + + // Credentials are required, so the call fails before any network request is made. + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.currentUser() + } + } + + @Test("Fetch", arguments: ServerVersion.allCases) + func fetch(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let user = try await server.currentUser() + + // The recording containers provision the account "admin", which logs in with its identifier. + #expect(user.id == "admin") + #expect(user.displayName.isEmpty == false) + } +} diff --git a/Tests/RainmakerTests/EntityTagTests.swift b/Tests/RainmakerTests/EntityTagTests.swift new file mode 100644 index 0000000..2d7559c --- /dev/null +++ b/Tests/RainmakerTests/EntityTagTests.swift @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About turning the `ETag` header of a notes response into the bare entity tag handed out, and that tag back into the value of an `If-None-Match` or `If-Match` header. +/// +/// These tests need neither a server nor response fixtures because both conversions are pure functions of the string. +/// +@Suite("Entity Tag") struct EntityTagTests { + @Test("Strips the quotes of a strong tag") + func unquotesStrongTag() { + #expect(#""c649e503de046daca1b998c2e52b2a94""#.unquotedEntityTag == "c649e503de046daca1b998c2e52b2a94") + } + + @Test("Strips the weakness marker and the quotes of a weak tag") + func unquotesWeakTag() { + // A proxy compressing the response may weaken the tag, while the server compares a conditional request against the strong tag it computed, so only the bare value can still produce a not modified answer. + #expect(#"W/"c649e503de046daca1b998c2e52b2a94""#.unquotedEntityTag == "c649e503de046daca1b998c2e52b2a94") + } + + @Test("Leaves a bare tag as it is") + func keepsBareTag() { + #expect("c649e503".unquotedEntityTag == "c649e503") + } + + @Test("Ignores surrounding whitespace") + func trimsWhitespace() { + #expect(#" "c649e503" "#.unquotedEntityTag == "c649e503") + } + + @Test("Leaves a lone quote alone") + func keepsLoneQuote() { + // A single quote is not a pair of quotes around an empty tag, so nothing is stripped from it. + #expect(#"""#.unquotedEntityTag == #"""#) + } + + @Test("Quotes a bare tag") + func quotesBareTag() { + #expect("c649e503".quotedEntityTag == #""c649e503""#) + } + + @Test("Does not quote a quoted tag twice") + func keepsQuotedTag() { + #expect(#""c649e503""#.quotedEntityTag == #""c649e503""#) + } + + @Test("Turns a weak tag into the strong header value the server compares against") + func roundTripsWeakTag() { + #expect(#"W/"c649e503""#.unquotedEntityTag.quotedEntityTag == #""c649e503""#) + } +} diff --git a/Tests/RainmakerTests/FixtureCanonicalizer.swift b/Tests/RainmakerTests/FixtureCanonicalizer.swift index b98242d..bf15a6e 100644 --- a/Tests/RainmakerTests/FixtureCanonicalizer.swift +++ b/Tests/RainmakerTests/FixtureCanonicalizer.swift @@ -6,7 +6,7 @@ import Foundation /// /// Normalizes recorded HTTP responses so that fixtures stay stable and free of host-specific or volatile values across regenerations. /// -/// This is used by ``URLRecordingSession`` before a response is written to disk. It rewrites the ephemeral container origin to a canonical one and replaces values which change on every server deployment (entity tags, file identifiers, timestamps, login tokens) with fixed placeholders. The replaced values are never asserted on by the tests and the chosen placeholders remain parseable by the production response parsing, which the replay-verify pass confirms. +/// This is used by ``URLRecordingSession`` before a response is written to disk. It rewrites the ephemeral container origin to a canonical one and replaces values which change on every server deployment (entity tags, file identifiers, timestamps, login tokens, the login moments and quota of the account) with fixed placeholders. The replaced values are never asserted on by the tests and the chosen placeholders remain parseable by the production response parsing, which the replay-verify pass confirms. /// struct FixtureCanonicalizer { /// @@ -22,9 +22,9 @@ struct FixtureCanonicalizer { let canonicalHost: String /// - /// The response header fields preserved in the recorded `Headers.txt`. + /// The response header fields preserved in the recorded `Headers.txt` of every response. /// - /// Everything else (volatile headers such as `Date`, `ETag` or request identifiers) is dropped to keep fixtures minimal and stable. The match is case-insensitive. + /// Everything else (volatile headers such as `Date`, `ETag` or request identifiers) is dropped to keep fixtures minimal and stable, unless ``notesHeaderFields`` keeps it for a response of the notes app. The match is case-insensitive. /// /// `X-Notes-API-Versions` has to be kept because ``Server/notes()`` refuses a notes app older than ``Notes/minimumAPIVersion`` and reads that header to decide, so a replayed response without it would look unsupported. Its value tracks whichever app release the app store served at recording time and therefore changes when that does. /// @@ -32,6 +32,22 @@ struct FixtureCanonicalizer { /// static let persistedHeaderFields = ["Content-Type", "X-Activity-First-Known", "X-Activity-Last-Given", "X-Notes-API-Versions"] + /// + /// The response header fields additionally preserved for a response of the notes app, which is one to a request whose path contains `/apps/notes/`, each paired with the canonical value written in place of the recorded one, or `nil` to keep the recorded value. + /// + /// These carry what ``NoteChanges`` reads from the headers rather than from the body, so a recording which dropped them would replay as a response without a moment to continue from, without an entity tag and, for a chunk, without a cursor. + /// The `ETag` of a listing is a hash of its body, which contains entity tags and identifiers differing per deployment, and `Last-Modified` and the chunk cursor contain the moment the server started answering, so all three are replaced with fixed values which ``Server`` still reads as a quoted tag, an HTTP date and a cursor respectively. + /// The number of pending notes is kept as recorded, because it only depends on the notes ``FixtureProvisioner`` seeds and on the requested chunk size. + /// + /// They are scoped to the notes app rather than added to ``persistedHeaderFields``, because WebDAV and other responses send an `ETag` and a `Last-Modified` header as well, whose churn would otherwise reach every fixture on every recording. + /// + static let notesHeaderFields: [(field: String, canonicalValue: String?)] = [ + ("ETag", "\"00000000000000000000000000000000\""), + ("Last-Modified", "Sat, 01 Jan 2000 00:00:00 GMT"), + ("X-Notes-Chunk-Cursor", "946684800-946684800-0"), + ("X-Notes-Chunk-Pending", nil), + ] + /// /// Create a canonicalizer for a given live server address. /// @@ -58,9 +74,11 @@ struct FixtureCanonicalizer { /// /// Serialize a response status and its headers into the plain-text format read back by ``HTTPURLResponse/init(from:for:)``. /// - /// The first line carries the status, followed by one line per preserved header field. Header fields are emitted sorted so that the output is deterministic. + /// The first line carries the status, followed by one line per preserved header field. Header fields are emitted sorted by name so that the output is deterministic. /// - func headersText(statusCode: Int, headerFields: [AnyHashable: Any]) -> Data { + /// The request URL decides which header fields are preserved, in the same way it decides which body replacements apply in ``canonicalizedBody(_:pathExtension:requestURL:)``: ``persistedHeaderFields`` for every response, and ``notesHeaderFields`` in addition for a response of the notes app. + /// + func headersText(statusCode: Int, headerFields: [AnyHashable: Any], requestURL: URL) -> Data { var lines = ["HTTP/1.1 \(statusCode) \(Self.reasonPhrase(forStatusCode: statusCode))"] let stringHeaders = headerFields.reduce(into: [String: String]()) { result, element in @@ -69,9 +87,15 @@ struct FixtureCanonicalizer { } } - for field in Self.persistedHeaderFields { + var fields: [(field: String, canonicalValue: String?)] = Self.persistedHeaderFields.map { ($0, nil) } + + if Self.path(of: requestURL).contains("/apps/notes/") { + fields += Self.notesHeaderFields + } + + for (field, canonicalValue) in fields.sorted(by: { $0.field.lowercased() < $1.field.lowercased() }) { if let match = stringHeaders.first(where: { $0.key.caseInsensitiveCompare(field) == .orderedSame }) { - lines.append("\(field): \(match.value)") + lines.append("\(field): \(canonicalValue ?? match.value)") } } @@ -98,8 +122,7 @@ struct FixtureCanonicalizer { text = text.replacingOccurrences(of: liveAuthority, with: canonicalHost) - // `URLComponents` is used rather than the modern `URL` path accessor for the same reason the initializer uses it: the accessor is unavailable on the older platforms the package supports. - let path = URLComponents(url: requestURL, resolvingAgainstBaseURL: false)?.path ?? "" + let path = Self.path(of: requestURL) var replacements = Self.volatileReplacements if path.contains("/login/v2") { @@ -110,6 +133,10 @@ struct FixtureCanonicalizer { replacements += Self.talkReplacements } + if path.contains("/cloud/user") { + replacements += Self.userReplacements + } + for (pattern, replacement) in replacements { text = text.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) } @@ -119,6 +146,15 @@ struct FixtureCanonicalizer { // MARK: - Private + /// + /// The path of a request URL, which decides which of the scoped rules apply to its response. + /// + /// `URLComponents` is used rather than the modern `URL` path accessor for the same reason the initializer uses it: the accessor is unavailable on the older platforms the package supports. + /// + private static func path(of requestURL: URL) -> String { + URLComponents(url: requestURL, resolvingAgainstBaseURL: false)?.path ?? "" + } + /// /// Regular-expression replacements applied to textual bodies, each pairing a pattern with its fixed replacement. /// @@ -144,8 +180,10 @@ struct FixtureCanonicalizer { /// Replacements applied only to the bodies of the login flow, which is the one API whose `token` is a secret rather than an identifier. /// /// These used to be part of ``volatileReplacements`` and applied to every body, which was safe only while the login flow was the sole API sending a field named `token`. Talk sends one too, and its value addresses a conversation in every further request, so the two must not share a rule. + /// The login URL is matched twice, because the server's JSON escapes its slashes as `\/`, which the plain pattern does not match and which left the flow tokens of recorded responses unredacted. /// private static let loginReplacements: [(pattern: String, replacement: String)] = [ + ("\\\\/login\\\\/v2\\\\/flow\\\\/[^\"<\\s]+", "\\\\/login\\\\/v2\\\\/flow\\\\/REDACTED"), ("/login/v2/flow/[^\"<\\s]+", "/login/v2/flow/REDACTED"), ("\"token\"[ ]*:[ ]*\"[^\"]*\"", "\"token\": \"REDACTED\""), ("\"appPassword\"[ ]*:[ ]*\"[^\"]*\"", "\"appPassword\": \"REDACTED\""), @@ -162,6 +200,23 @@ struct FixtureCanonicalizer { ("\"avatarVersion\"[ ]*:[ ]*\"[^\"]*\"", "\"avatarVersion\": \"00000000\""), ] + /// + /// Replacements applied only to the bodies of the provisioning API's account details, which ``Server/currentUser()`` reads from `/cloud/user` and which `/cloud/users/` sends in the same shape. + /// + /// The moments of the first and the last login change whenever the account logs in, which every recording does, and the numbers of the quota follow whatever the files of the account happen to occupy at the time of the recording. + /// None of them is decoded by ``CurrentUserResponse``, so the fixed values only need to stay valid JSON numbers. + /// The rules are scoped to these paths because the field names, `"used"` and `"total"` in particular, are too generic to rewrite in every body. + /// + private static let userReplacements: [(pattern: String, replacement: String)] = [ + ("\"firstLoginTimestamp\"[ ]*:[ ]*-?[0-9]+", "\"firstLoginTimestamp\": 946684800"), + ("\"lastLoginTimestamp\"[ ]*:[ ]*-?[0-9]+", "\"lastLoginTimestamp\": 946684800"), + ("\"lastLogin\"[ ]*:[ ]*-?[0-9]+", "\"lastLogin\": 946684800000"), + ("\"free\"[ ]*:[ ]*-?[0-9.eE+]+", "\"free\": 0"), + ("\"used\"[ ]*:[ ]*-?[0-9.eE+]+", "\"used\": 0"), + ("\"total\"[ ]*:[ ]*-?[0-9.eE+]+", "\"total\": 0"), + ("\"relative\"[ ]*:[ ]*-?[0-9.eE+]+", "\"relative\": 0"), + ] + /// /// Maps an HTTP status code to its reason phrase for the codes this client encounters, defaulting to an empty phrase. /// @@ -182,6 +237,7 @@ struct FixtureCanonicalizer { case 404: "Not Found" case 405: "Method Not Allowed" case 409: "Conflict" + case 412: "Precondition Failed" case 423: "Locked" case 500: "Internal Server Error" case 507: "Insufficient Storage" diff --git a/Tests/RainmakerTests/FixtureCanonicalizerTests.swift b/Tests/RainmakerTests/FixtureCanonicalizerTests.swift index 3d6b5c3..d26e253 100644 --- a/Tests/RainmakerTests/FixtureCanonicalizerTests.swift +++ b/Tests/RainmakerTests/FixtureCanonicalizerTests.swift @@ -25,6 +25,19 @@ import Testing return String(data: result, encoding: .utf8) ?? "" } + /// + /// Serialize headers as if they had been the response to a request for the given path and return the resulting lines. + /// + /// The path matters for the same reason it does in ``canonicalize(_:pathExtension:path:)``: which header fields are preserved depends on the API the response belongs to. + /// + func headerLines(statusCode: Int, headerFields: [AnyHashable: Any], path: String = "/remote.php/dav/files/admin/") throws -> [String] { + let requestURL = URL(string: "http://localhost:54540\(path)")! + let data = canonicalizer.headersText(statusCode: statusCode, headerFields: headerFields, requestURL: requestURL) + let text = try #require(String(data: data, encoding: .utf8)) + + return text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) + } + @Test("Rewrites the live origin to the canonical one") func rewritesOrigin() { let body = "{\"server\":\"http://localhost:54540/remote.php\"}" @@ -60,6 +73,15 @@ import Testing #expect(result.contains("http://localhost/login/v2/flow/REDACTED")) } + @Test("Redacts login tokens in escaped login URLs") + func redactsEscapedLoginURLTokens() { + let body = "{\"login\":\"http:\\/\\/localhost:54540\\/login\\/v2\\/flow\\/XYZ789\"}" + let result = canonicalize(body, pathExtension: "json", path: "/index.php/login/v2") + + #expect(result.contains("\\/login\\/v2\\/flow\\/REDACTED")) + #expect(result.contains("XYZ789") == false) + } + @Test("Leaves login secrets alone outside the login flow") func leavesLoginSecretsScoped() { let body = "{\"token\": \"abc123\", \"appPassword\": \"secret\"}" @@ -92,6 +114,34 @@ import Testing #expect(result.contains("\"hello-v2-token-key\": \"REDACTED\"")) } + @Test("Redacts the login moments and the quota of the current user") + func redactsCurrentUserFields() { + let body = #"{"ocs":{"data":{"id":"admin","firstLoginTimestamp":1788879000,"lastLoginTimestamp":1788879440,"lastLogin":1788879440000,"quota":{"free":-3,"used":5242880,"total":-3,"relative":12.34,"quota":-3},"displayname":"admin"}}}"# + let result = canonicalize(body, pathExtension: "json", path: "/ocs/v2.php/cloud/user") + + #expect(result.contains("\"firstLoginTimestamp\": 946684800,")) + #expect(result.contains("\"lastLoginTimestamp\": 946684800,")) + #expect(result.contains("\"lastLogin\": 946684800000,")) + #expect(result.contains("\"free\": 0,")) + #expect(result.contains("\"used\": 0,")) + #expect(result.contains("\"total\": 0,")) + #expect(result.contains("\"relative\": 0,")) + + // The quota setting itself and the fields the client decodes are stable and stay as recorded. + #expect(result.contains("\"quota\":-3}")) + #expect(result.contains("\"id\":\"admin\"")) + #expect(result.contains("\"displayname\":\"admin\"")) + } + + @Test("Leaves quota-like fields alone outside the account details") + func leavesCurrentUserFieldsScoped() { + let body = #"{"used":5242880,"total":10485760,"lastLogin":1788879440000}"# + let result = canonicalize(body, pathExtension: "json", path: "/ocs/v2.php/apps/notes/api/v1.4/notes") + + // These names are generic enough to mean something else in another API, which is why the rules only apply to the account details. + #expect(result == body) + } + @Test("Redacts the volatile note entity tags") func redactsNoteEntityTags() { let body = #"[{"id":86,"modified":1700000000,"etag":"c649e503de046daca1b998c2e52b2a94"}]"# @@ -119,13 +169,71 @@ import Testing "Server": "nginx", ] - let data = canonicalizer.headersText(statusCode: 207, headerFields: headers) - let text = try #require(String(data: data, encoding: .utf8)) - let lines = text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) + let lines = try headerLines(statusCode: 207, headerFields: headers) #expect(lines.first == "HTTP/1.1 207 Multi-Status") #expect(lines.contains("Content-Type: application/xml; charset=utf-8")) #expect(lines.contains { $0.hasPrefix("Date") } == false) #expect(lines.contains { $0.hasPrefix("Server") } == false) } + + @Test("Keeps the notes sync headers with canonical values") + func keepsNotesHeaders() throws { + let headers: [AnyHashable: Any] = [ + "Content-Type": "application/json; charset=utf-8", + "Date": "Tue, 06 Oct 2026 10:00:00 GMT", + "ETag": "\"c649e503de046daca1b998c2e52b2a94\"", + "Last-Modified": "Tue, 06 Oct 2026 10:00:00 GMT", + "X-Notes-API-Versions": "0.2, 1.3, 1.4", + "X-Notes-Chunk-Cursor": "1791280800-1791280799-98", + "X-Notes-Chunk-Pending": "1", + ] + + let lines = try headerLines(statusCode: 200, headerFields: headers, path: "/index.php/apps/notes/api/v1/notes") + + // The listing reports what to continue from in its headers, so those have to survive, with the values differing per deployment and per moment replaced by fixed ones which still read as a quoted tag, an HTTP date and a cursor. + #expect(lines == [ + "HTTP/1.1 200 OK", + "Content-Type: application/json; charset=utf-8", + "ETag: \"00000000000000000000000000000000\"", + "Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT", + "X-Notes-API-Versions: 0.2, 1.3, 1.4", + "X-Notes-Chunk-Cursor: 946684800-946684800-0", + "X-Notes-Chunk-Pending: 1", + ]) + } + + @Test("Leaves out the notes sync headers a notes response does not send") + func leavesOutAbsentNotesHeaders() throws { + let headers: [AnyHashable: Any] = [ + "Content-Type": "application/json; charset=utf-8", + "X-Notes-API-Versions": "0.2, 1.3, 1.4", + ] + + let lines = try headerLines(statusCode: 200, headerFields: headers, path: "/index.php/apps/notes/api/v1/settings") + + // A canonical value only replaces a header which was recorded, so a response without one does not gain it. + #expect(lines == ["HTTP/1.1 200 OK", "Content-Type: application/json; charset=utf-8", "X-Notes-API-Versions: 0.2, 1.3, 1.4"]) + } + + @Test("Drops the entity tag and modification date outside the notes app") + func dropsSyncHeadersElsewhere() throws { + let headers: [AnyHashable: Any] = [ + "Content-Type": "application/xml; charset=utf-8", + "ETag": "\"69455eb955bf5\"", + "Last-Modified": "Fri, 19 Dec 2025 14:18:33 GMT", + ] + + let lines = try headerLines(statusCode: 200, headerFields: headers, path: "/remote.php/dav/files/admin/Readme.md") + + // WebDAV sends both headers as well, but nothing reads them from there, and keeping them would make every recording churn. + #expect(lines == ["HTTP/1.1 200 OK", "Content-Type: application/xml; charset=utf-8"]) + } + + @Test("Names the reason of a failed precondition") + func reasonPhraseOfPreconditionFailed() throws { + let lines = try headerLines(statusCode: 412, headerFields: [:], path: "/index.php/apps/notes/api/v1/notes/1") + + #expect(lines == ["HTTP/1.1 412 Precondition Failed"]) + } } diff --git a/Tests/RainmakerTests/HTTPDateTests.swift b/Tests/RainmakerTests/HTTPDateTests.swift new file mode 100644 index 0000000..6dee1f1 --- /dev/null +++ b/Tests/RainmakerTests/HTTPDateTests.swift @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About reading the HTTP dates of the `Last-Modified` header and of the WebDAV `getlastmodified` property. +/// +/// These tests need neither a server nor response fixtures because the conversion is a pure function of the string. +/// +@Suite("HTTP Date") struct HTTPDateTests { + @Test("Reads a date in GMT") + func readsGMT() throws { + let date = try #require(Date(httpDate: "Tue, 14 Nov 2023 22:13:20 GMT")) + #expect(date == Date(timeIntervalSince1970: 1_700_000_000)) + } + + @Test("Reads the canonical value of the recorded fixtures") + func readsCanonicalValue() throws { + // The fixture canonicalizer writes this in place of every recorded Last-Modified header of the notes app, so it has to stay readable. + let date = try #require(Date(httpDate: "Sat, 01 Jan 2000 00:00:00 GMT")) + #expect(date == Date(timeIntervalSince1970: 946_684_800)) + } + + @Test("Reads a date with a numeric offset") + func readsNumericOffset() throws { + let date = try #require(Date(httpDate: "Tue, 14 Nov 2023 23:13:20 +0100")) + #expect(date == Date(timeIntervalSince1970: 1_700_000_000)) + } + + @Test("Reads the epoch the WebDAV fixtures are canonicalized to") + func readsEpoch() throws { + let date = try #require(Date(httpDate: "Thu, 01 Jan 1970 00:00:00 GMT")) + #expect(date == Date(timeIntervalSince1970: 0)) + } + + @Test("Rejects what is not an HTTP date", arguments: ["", "yesterday", "1700000000", "2023-11-14T22:13:20Z"]) + func rejectsGarbage(_ string: String) { + #expect(Date(httpDate: string) == nil) + } +} diff --git a/Tests/RainmakerTests/LoginRequestTests.swift b/Tests/RainmakerTests/LoginRequestTests.swift new file mode 100644 index 0000000..84c9188 --- /dev/null +++ b/Tests/RainmakerTests/LoginRequestTests.swift @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About how ``Server/login()`` and ``Server/poll(_:)`` map the statuses and bodies a server answers with onto their results and ``RainmakerError``, and how the deprecated ``Server/poll(_:token:)`` keeps its behaviour. +/// +/// These tests deliberately do not use the fixture tree: a live Nextcloud server cannot be made to answer with an HTML page, a missing endpoint or a server error, and ``URLTestSession`` does not look at request bodies. The replayed counterparts are in ``LoginTests`` and ``PollTests``. A capturing ``MockRequesting`` is used instead. +/// +@Suite("Login Requests") struct LoginRequestTests { + /// + /// The address of the server the tests begin login flows on. + /// + let serverAddress = URL(string: "http://localhost/")! + + /// + /// A response body with a login flow as a Nextcloud server sends it when a flow begins. + /// + let flowPayload = #"{"poll":{"token":"pollToken123","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/loginToken456"}"# + + /// + /// A response body with the credentials of a completed login flow as a Nextcloud server sends them. + /// + let resultPayload = #"{"server":"http:\/\/localhost","loginName":"admin@example.com","appPassword":"secret"}"# + + /// + /// A response body as a web server which is not Nextcloud answers with, which ``Server/login()`` must not take for a login flow. + /// + let htmlPayload = "WelcomeIt works!" + + /// + /// The login flow the polling tests check, as ``Server/login()`` would have returned it for ``flowPayload``. + /// + let flow = LoginFlow(endpoint: URL(string: "http://localhost/login/v2/poll")!, entry: URL(string: "http://localhost/login/v2/flow/loginToken456")!, token: "pollToken123") + + /// + /// Build a server without credentials whose session is the given mock, as a client beginning a login has no credentials yet. + /// + private func makeServer(session: any Requesting) -> Server { + Server(address: serverAddress, session: session, userAgent: "RainmakerTests") + } + + // MARK: - Beginning + + @Test("Beginning Returns The Login Flow") + func beginReturnsFlow() async throws { + let session = MockRequesting(string: flowPayload) + let flow = try await makeServer(session: session).login() + let request = try #require(session.requests.first) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/index.php/login/v2") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(flow == self.flow) + } + + @Test("Beginning On Something Other Than Nextcloud Fails To Decode") + func beginOnHTML() async throws { + let session = MockRequesting(string: htmlPayload, headerFields: ["Content-Type": "text/html"]) + + await #expect(throws: RainmakerError.responseDecodingFailed(reason: "The server did not answer with a login flow, so it is probably not a Nextcloud server.")) { + _ = try await makeServer(session: session).login() + } + } + + @Test("Beginning On A Missing Endpoint Is Not Found") + func beginOnMissingEndpoint() async throws { + let session = MockRequesting(string: htmlPayload, statusCode: 404, headerFields: ["Content-Type": "text/html"]) + + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).login() + } + } + + @Test("Beginning On A Failing Server Reports The Status") + func beginOnFailingServer() async throws { + let session = MockRequesting(string: "", statusCode: 500) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 500)) { + _ = try await makeServer(session: session).login() + } + } + + // MARK: - Polling + + @Test("Polling Posts The Token As A Form Field") + func pollPostsToken() async throws { + let session = MockRequesting(string: "[]", statusCode: 404) + _ = try await makeServer(session: session).poll(flow) + let request = try #require(session.requests.first) + let body = try #require(request.httpBody) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "POST") + #expect(request.url == flow.endpoint) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(String(decoding: body, as: UTF8.self) == "token=pollToken123") + } + + @Test("Polling Encodes The Token") + func pollEncodesToken() async throws { + let session = MockRequesting(string: "[]", statusCode: 404) + let flow = LoginFlow(endpoint: flow.endpoint, entry: flow.entry, token: "a+b&c=d") + _ = try await makeServer(session: session).poll(flow) + let body = try #require(session.requests.first?.httpBody) + + #expect(String(decoding: body, as: UTF8.self) == "token=a%2Bb%26c%3Dd") + } + + @Test("Polling A Pending Flow Returns Nil") + func pollPending() async throws { + let session = MockRequesting(string: "[]", statusCode: 404, headerFields: ["Content-Type": "application/json; charset=utf-8"]) + let result = try await makeServer(session: session).poll(flow) + + #expect(result == nil) + } + + @Test("Polling A Completed Flow Returns The Result") + func pollCompleted() async throws { + let session = MockRequesting(string: resultPayload) + let result = try await makeServer(session: session).poll(flow) + + // The login name is kept as the user typed it, which is not necessarily the identifier of the account. + #expect(try result == LoginResult(name: "admin@example.com", password: "secret", server: #require(URL(string: "http://localhost")))) + } + + @Test("Polling A Missing Endpoint Is Not Found") + func pollMissingEndpoint() async throws { + let session = MockRequesting(string: htmlPayload, statusCode: 404, headerFields: ["Content-Type": "text/html"]) + + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).poll(flow) + } + } + + @Test("Polling A Failing Server Reports The Status") + func pollFailingServer() async throws { + let session = MockRequesting(string: "", statusCode: 503) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 503)) { + _ = try await makeServer(session: session).poll(flow) + } + } + + @Test("Polling Something Other Than Credentials Fails To Decode") + func pollUndecodable() async throws { + let session = MockRequesting(string: htmlPayload, headerFields: ["Content-Type": "text/html"]) + + await #expect(throws: RainmakerError.responseDecodingFailed(reason: "The server did not answer with the result of the login flow.")) { + _ = try await makeServer(session: session).poll(flow) + } + } + + @Test("Cancelling A Poll Cancels Its Request") + func cancelPoll() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(string: "[]", statusCode: 404)) + let server = makeServer(session: session) + + let polling = Task { + try await server.poll(flow) + } + + // The request runs within the calling task, so a login sheet which is dismissed stops polling at once. + #expect(try await eventually { session.suspendedCount == 1 }) + polling.cancel() + + let result = await polling.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + } + + // MARK: - Deprecated Polling + + @Test("Deprecated Polling Still Throws While Pending") + func deprecatedPollPending() async throws { + let session = MockRequesting(string: "[]", statusCode: 404) + let server: any Serving = makeServer(session: session) + + await #expect(throws: RainmakerError.responseDecodingFailed(reason: "The server returned no login flow result on polling.")) { + _ = try await server.poll(flow.endpoint, token: flow.token) + } + + let body = try #require(session.requests.first?.httpBody) + + #expect(String(decoding: body, as: UTF8.self) == "token=pollToken123") + } + + @Test("Deprecated Polling Still Returns The Result") + func deprecatedPollCompleted() async throws { + let session = MockRequesting(string: resultPayload) + let server: any Serving = makeServer(session: session) + let result = try await server.poll(flow.endpoint, token: flow.token) + + #expect(result.name == "admin@example.com") + } +} diff --git a/Tests/RainmakerTests/LoginTests.swift b/Tests/RainmakerTests/LoginTests.swift index 13b4e4c..526e8c2 100644 --- a/Tests/RainmakerTests/LoginTests.swift +++ b/Tests/RainmakerTests/LoginTests.swift @@ -9,13 +9,16 @@ import Testing /// /// Login flow related tests. /// +/// The statuses and bodies a server which is not Nextcloud may answer with are covered with ``MockRequesting`` in ``LoginRequestTests``. +/// @Suite("Login") struct LoginTests: ServerTesting { @Test("Fetch Login Information", arguments: ServerVersion.allCases) func fetchLoginInformation(_ serverVersion: ServerVersion) async throws { let server = try makeServer(serverVersion: serverVersion) + let flow = try await server.login() - await #expect(throws: Never.self) { - _ = try await server.login() - } + #expect(flow.endpoint.path.hasSuffix("/login/v2/poll")) + #expect(flow.entry.path.contains("/login/v2/flow/")) + #expect(flow.token.isEmpty == false) } } diff --git a/Tests/RainmakerTests/MockRequesting.swift b/Tests/RainmakerTests/MockRequesting.swift index ab1d02d..f412392 100644 --- a/Tests/RainmakerTests/MockRequesting.swift +++ b/Tests/RainmakerTests/MockRequesting.swift @@ -32,10 +32,46 @@ final class MockRequesting: Requesting, @unchecked Sendable { /// private var capturedRequests = [URLRequest]() + /// + /// The local files uploads were sent from so far, in order. + /// + private var capturedUploadSources = [URL]() + + /// + /// The temporary files downloads were handed over in so far, in order. + /// + private var capturedDownloadLocations = [URL]() + + /// + /// The temporary files ``download(for:delegate:)`` handed over so far, in the order the downloads were issued, which a test checks to have been moved or removed by the caller, as ``Server/downloadAttachment(at:ofNote:to:force:)`` promises. + /// + var downloadLocations: [URL] { + lock.lock() + + defer { + lock.unlock() + } + + return capturedDownloadLocations + } + + /// + /// The local files this mock was asked to upload from so far, in the order the uploads were issued, which a test checks to have been removed after a staged upload. + /// + var uploadSources: [URL] { + lock.lock() + + defer { + lock.unlock() + } + + return capturedUploadSources + } + /// /// The requests this mock received so far, in the order they were issued. /// - /// A request received through ``upload(for:fromFile:delegate:)`` carries the bytes of the uploaded file in its `httpBody`, which a real session leaves empty for a file upload, so that a test can assert on what would have been sent. + /// A request received through ``upload(for:fromFile:delegate:)`` carries the bytes of the uploaded file in its `httpBody`, which a real session leaves empty for a file upload, so that a test can assert on what would have been sent. A request received through ``download(for:delegate:)`` is answered like any other, with the body written to a temporary file. /// var requests: [URLRequest] { lock.lock() @@ -86,7 +122,7 @@ final class MockRequesting: Requesting, @unchecked Sendable { /// /// Record a request, from a synchronous context because the lock may not be taken from an asynchronous one. /// - private func capture(_ request: URLRequest) { + private func capture(_ request: URLRequest, uploadingFrom source: URL? = nil, downloadingTo location: URL? = nil) { lock.lock() defer { @@ -94,6 +130,14 @@ final class MockRequesting: Requesting, @unchecked Sendable { } capturedRequests.append(request) + + if let source { + capturedUploadSources.append(source) + } + + if let location { + capturedDownloadLocations.append(location) + } } /// @@ -110,15 +154,21 @@ final class MockRequesting: Requesting, @unchecked Sendable { return answer(request) } - func download(for _: URLRequest, delegate _: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { - throw MockWebSocketError.closed + func download(for request: URLRequest, delegate _: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + // Like a download task, the body is handed over in a temporary file of its own, which the caller is expected to move or remove. + let location = FileManager.default.temporaryDirectory.appendingPathComponent("MockRequesting-\(UUID().uuidString).download") + capture(request, downloadingTo: location) + let (body, response) = answer(request) + try body.write(to: location) + + return (location, response) } func upload(for request: URLRequest, fromFile fileURL: URL, delegate _: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { // The file is read right away because the caller may remove it as soon as this returns, as ``Server`` does with a staged chunk. var captured = request captured.httpBody = try Data(contentsOf: fileURL) - capture(captured) + capture(captured, uploadingFrom: fileURL) return answer(request) } } diff --git a/Tests/RainmakerTests/MockWebSocketConnecting.swift b/Tests/RainmakerTests/MockWebSocketConnecting.swift index 6b626e3..08ad94a 100644 --- a/Tests/RainmakerTests/MockWebSocketConnecting.swift +++ b/Tests/RainmakerTests/MockWebSocketConnecting.swift @@ -21,10 +21,28 @@ final class MockWebSocketConnecting: WebSocketConnecting, @unchecked Sendable { private let channels: [MockWebSocketChannel] /// - /// The index of the next channel to vend. + /// The index of the next scripted channel to vend. /// private var index = 0 + /// + /// How many channels were vended so far, counting those vended after the scripted ones ran out. + /// + private var vended = 0 + + /// + /// How many channels ``channel(for:)`` was asked for so far, which a test checks to prove that no WebSocket was opened at all or that a connection was attempted a given number of times. + /// + var openedChannelCount: Int { + lock.lock() + + defer { + lock.unlock() + } + + return vended + } + /// /// Create a connector vending the given channels in order. /// @@ -39,6 +57,8 @@ final class MockWebSocketConnecting: WebSocketConnecting, @unchecked Sendable { lock.lock() defer { lock.unlock() } + vended += 1 + guard index < channels.count else { return MockWebSocketChannel(frames: []) } diff --git a/Tests/RainmakerTests/ModelEncodingTests.swift b/Tests/RainmakerTests/ModelEncodingTests.swift index 0482d8a..faf8576 100644 --- a/Tests/RainmakerTests/ModelEncodingTests.swift +++ b/Tests/RainmakerTests/ModelEncodingTests.swift @@ -56,13 +56,155 @@ import Testing @Test("Note Encodes Under Its Property Names") func note() throws { let payload = """ - {"id":76,"etag":"be284e00488c61c101ee28309d235e0b","readonly":false,"modified":1376753464,"title":"New note","category":"sub-directory","content":"New note","favorite":false,"error":false,"errorType":""} + {"id":76,"etag":"be284e00488c61c101ee28309d235e0b","readonly":false,"modified":1376753464,"title":"New note","category":"sub-directory","content":"New note","favorite":false,"error":false,"errorType":"","internalPath":"/Notes/sub-directory/New note.md","shareTypes":[0,3],"isShared":true} """ let keys = try encodedKeys(of: Note.self, from: payload) - #expect(keys.isDisjoint(with: ["etag", "readonly", "favorite", "modified", "error"])) - #expect(keys == ["id", "entityTag", "isReadOnly", "title", "category", "content", "hasError", "errorType", "isFavorite", "modification"]) + #expect(keys.isDisjoint(with: ["etag", "readonly", "favorite", "modified", "error", "internalPath"])) + #expect(keys == ["id", "entityTag", "isReadOnly", "title", "category", "content", "hasError", "errorType", "isFavorite", "modification", "path", "isShared", "shareTypes"]) + } + + @Test("Note Without Path And Shares Decodes With Defaults") + func noteWithoutPathAndShares() throws { + let payload = """ + {"id":76,"etag":"be284e00488c61c101ee28309d235e0b","readonly":false,"modified":1376753464,"title":"New note","category":"","content":"New note","favorite":false,"error":false,"errorType":""} + """ + + let note = try JSONDecoder().decode(Note.self, from: Data(payload.utf8)) + + // A payload written by hand may leave these out, which must not fail the decoding. + #expect(note.path == nil) + #expect(note.isShared == false) + #expect(note.shareTypes.isEmpty) + + // The memberwise initializer defaults to exactly what such a payload decodes to, so a fake built with it compares equal to the decoded note. + let made = Note(id: 76, entityTag: "be284e00488c61c101ee28309d235e0b", title: "New note", content: "New note", modification: Date(timeIntervalSince1970: 1_376_753_464)) + #expect(made == note) + } + + @Test("Note Path And Shares Decode From The Server's Names") + func notePathAndShares() throws { + let payload = """ + {"id":76,"etag":"be284e00488c61c101ee28309d235e0b","readonly":false,"modified":1376753464,"title":"New note","category":"sub-directory","content":"New note","favorite":false,"error":false,"errorType":"","internalPath":"/Notes/sub-directory/New note.md","shareTypes":[0,3,99],"isShared":true} + """ + + let note = try JSONDecoder().decode(Note.self, from: Data(payload.utf8)) + + #expect(note.path == "/Notes/sub-directory/New note.md") + #expect(note.isShared) + + // A kind of share without a named constant keeps its number rather than failing the decoding of the note. + #expect(note.shareTypes == [.user, .link, ShareType(rawValue: 99)]) + } + + @Test("Share Type Encodes As The Number The Server Sent") + func shareType() throws { + let types = try JSONDecoder().decode([ShareType].self, from: Data("[0, 1, 3, 4, 6, 10, 12, 15, 42]".utf8)) + #expect(types == [.user, .group, .link, .email, .federated, .room, .deck, .scienceMesh, ShareType(rawValue: 42)]) + + let data = try JSONEncoder().encode(types) + let array = try #require(try JSONSerialization.jsonObject(with: data) as? [Int]) + + // Unlike the enums with named cases, a kind of share has only its number to encode, known or not. + #expect(array == [0, 1, 3, 4, 6, 10, 12, 15, 42]) + #expect(ShareType.link.description == "link") + #expect(ShareType(rawValue: 42).description == "42") + } + + @Test("Note Changes Encode Every Property And No Derived One") + func noteChanges() throws { + let note = Note(id: 7, entityTag: "9cf1", title: "Changed", content: "text", modification: Date(timeIntervalSince1970: 1_700_000_000)) + let changes = NoteChanges(changed: [note], unchanged: [8], lastModified: Date(timeIntervalSince1970: 1_700_000_000), entityTag: "c649e503", chunkCursor: "1700000000-1699999999-7", pendingCount: 3) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(changes) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // The completeness is derived from the cursor alone, so it is not encoded beside it. + #expect(Set(object.keys) == ["changed", "unchanged", "lastModified", "entityTag", "chunkCursor", "pendingCount"]) + #expect(object["unchanged"] as? [Int] == [8]) + #expect(object["lastModified"] as? Double == 1_700_000_000) + #expect(object["entityTag"] as? String == "c649e503") + #expect(object["chunkCursor"] as? String == "1700000000-1699999999-7") + #expect(object["pendingCount"] as? Int == 3) + } + + @Test("Complete Note Changes Encode Their Absent Header Values As Null") + func completeNoteChanges() throws { + let changes = NoteChanges(changed: [], unchanged: [8]) + let data = try JSONEncoder().encode(changes) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // Absent values are encoded as null so that the encoded form always has the same keys. + #expect(changes.isComplete) + #expect(object["lastModified"] is NSNull) + #expect(object["entityTag"] is NSNull) + #expect(object["chunkCursor"] is NSNull) + #expect(object["pendingCount"] is NSNull) + } + + @Test("Note Summary Encodes Under Its Property Names") + func noteSummary() throws { + let payload = """ + {"id":76,"title":"New note","modified":1376753464,"category":"sub-directory","favorite":false,"readonly":false,"internalPath":"/Notes/sub-directory/New note.md","shareTypes":[0,3],"isShared":true,"error":false,"errorType":"","etag":"be284e00488c61c101ee28309d235e0b"} + """ + + let keys = try encodedKeys(of: NoteSummary.self, from: payload) + + // Neither the server's naming nor the error fields, which a listing without content always sends as no error, may survive a round trip. + #expect(keys.isDisjoint(with: ["etag", "readonly", "favorite", "modified", "internalPath", "error", "hasError", "errorType", "content"])) + #expect(keys == ["id", "entityTag", "isReadOnly", "title", "category", "isFavorite", "modification", "path", "isShared", "shareTypes"]) + } + + @Test("Note Summary Without Path And Shares Decodes With Defaults") + func noteSummaryWithoutPathAndShares() throws { + let payload = """ + {"id":76,"etag":"be284e00488c61c101ee28309d235e0b","readonly":false,"modified":1376753464,"title":"New note","category":"","favorite":false} + """ + + let summary = try JSONDecoder().decode(NoteSummary.self, from: Data(payload.utf8)) + + // A payload written by hand may leave these out, as may one without the error fields, which a summary does not read. + #expect(summary.path == nil) + #expect(summary.isShared == false) + #expect(summary.shareTypes.isEmpty) + #expect(summary.modification == Date(timeIntervalSince1970: 1_376_753_464)) + } + + @Test("Note Summary Changes Encode Every Property And No Derived One") + func noteSummaryChanges() throws { + let summary = NoteSummary(id: 7, entityTag: "9cf1", title: "Changed", modification: Date(timeIntervalSince1970: 1_700_000_000)) + let changes = NoteSummaryChanges(changed: [summary], unchanged: [8], lastModified: Date(timeIntervalSince1970: 1_700_000_000), entityTag: "c649e503", chunkCursor: "1700000000-1699999999-7", pendingCount: 3) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(changes) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let changed = try #require(object["changed"] as? [[String: Any]]) + + // The same keys as those of note changes, with completeness derived from the cursor alone. + #expect(Set(object.keys) == ["changed", "unchanged", "lastModified", "entityTag", "chunkCursor", "pendingCount"]) + #expect(changed.first?["entityTag"] as? String == "9cf1") + #expect(changed.first?["content"] == nil) + #expect(object["unchanged"] as? [Int] == [8]) + #expect(object["lastModified"] as? Double == 1_700_000_000) + #expect(object["chunkCursor"] as? String == "1700000000-1699999999-7") + #expect(object["pendingCount"] as? Int == 3) + } + + @Test("Complete Note Summary Changes Encode Their Absent Header Values As Null") + func completeNoteSummaryChanges() throws { + let changes = NoteSummaryChanges(changed: [], unchanged: [8]) + let data = try JSONEncoder().encode(changes) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(changes.isComplete) + #expect(object["lastModified"] is NSNull) + #expect(object["entityTag"] is NSNull) + #expect(object["chunkCursor"] is NSNull) + #expect(object["pendingCount"] is NSNull) } @Test("Collective Encodes Under Its Property Names") @@ -96,13 +238,54 @@ import Testing @Test("Note Settings Encode Under Their Property Names") func notesSettings() throws { let payload = """ - {"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} + {"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} """ let keys = try encodedKeys(of: NotesSettings.self, from: payload) - // The server already names these the way this library does, so the two key sets coincide rather than needing a translation. The undocumented field must not survive. - #expect(keys == ["notesPath", "fileSuffix"]) + // The names of the two preferences the server spells differently must not survive a round trip. + #expect(keys.isDisjoint(with: ["showHidden", "loadRecentOnStartUp"])) + #expect(keys == ["notesPath", "fileSuffix", "noteMode", "showsHiddenFiles", "loadsRecentNoteOnStartUp"]) + } + + @Test("Note Settings Decode The Preferences Of Newer Releases") + func notesSettingsPreferences() throws { + let payload = """ + {"notesPath":"Notizen","fileSuffix":".md","noteMode":"preview","showHidden":true,"loadRecentOnStartUp":false} + """ + + let settings = try JSONDecoder().decode(NotesSettings.self, from: Data(payload.utf8)) + + #expect(settings == NotesSettings(notesPath: "Notizen", fileSuffix: ".md", noteMode: .preview, showsHiddenFiles: true, loadsRecentNoteOnStartUp: false)) + + let data = try JSONEncoder().encode(settings) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // The mode encodes as the string the server uses for it. + #expect(object["noteMode"] as? String == "preview") + } + + @Test("Note Settings Tolerate An Unknown Mode And Missing Preferences") + func notesSettingsUnknownMode() throws { + let payload = """ + {"notesPath":"Notes","fileSuffix":".txt","noteMode":"wysiwyg"} + """ + + let settings = try JSONDecoder().decode(NotesSettings.self, from: Data(payload.utf8)) + + // A mode of a future release reads as absent rather than failing the whole lookup, and the preferences older releases do not send read as absent as well. + #expect(settings.noteMode == nil) + #expect(settings.showsHiddenFiles == nil) + #expect(settings.loadsRecentNoteOnStartUp == nil) + #expect(settings.fileSuffix == ".txt") + + let data = try JSONEncoder().encode(settings) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // Absent values are encoded as null so that the encoded form always has the same keys. + #expect(object["noteMode"] is NSNull) + #expect(object["showsHiddenFiles"] is NSNull) + #expect(object["loadsRecentNoteOnStartUp"] is NSNull) } @Test("Navigation Item Encodes Under Its Property Names") diff --git a/Tests/RainmakerTests/NoteAttachmentPathTests.swift b/Tests/RainmakerTests/NoteAttachmentPathTests.swift new file mode 100644 index 0000000..0f7034f --- /dev/null +++ b/Tests/RainmakerTests/NoteAttachmentPathTests.swift @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Rainmaker +import Testing + +/// +/// About resolving the path of an attachment relative to the folder of a note's category through ``NoteAttachmentPath/resolve(_:relativeTo:)``, as the notes app does when it serves ``Server/attachment(at:ofNote:)``. +/// +/// These tests need neither a server nor response fixtures because the function is pure. +/// +@Suite("Note Attachment Path") struct NoteAttachmentPathTests { + @Test("Resolves a path relative to the folder of a category", arguments: [ + (".attachments.12/a.png", "", ".attachments.12/a.png"), + (".attachments.12/a.png", "Work", "Work/.attachments.12/a.png"), + (".attachments.12/a.png", "Work/Projects", "Work/Projects/.attachments.12/a.png"), + ("../img/a.png", "Work/Projects", "Work/img/a.png"), + ("../../../../x.png", "Work", "x.png"), + ("../x.png", "", "x.png"), + ("..\\x.png", "Work", "x.png"), + ("img\\a.png", "Work", "Work/img/a.png"), + ("//a///b.png/", "Work", "Work/a/b.png"), + ("./a.png", "Work", "Work/a.png"), + ("a/./../b.png", "Work", "Work/a/b.png"), + ("a/b/../../c.png", "Work", "Work/c.png"), + ("...png", "Work", "Work/...png"), + ("Café é (1).png", "Reisen", "Reisen/Café é (1).png"), + ("Cafe\u{301}.png", "", "Cafe\u{301}.png"), + ("😀 & + %.png", "", "😀 & + %.png"), + ("a%20b.png", "", "a%20b.png"), + ]) + func resolve(path: String, category: String, expected: String) { + let resolved = NoteAttachmentPath.resolve(path, relativeTo: category) + + #expect(resolved.map { Array($0.unicodeScalars) } == Array(expected.unicodeScalars)) + } + + @Test("Resolves nothing for the notes folder itself", arguments: [ + ("", ""), + ("/", ""), + ("..", "Work"), + ("../..", "Work/Projects"), + (".", ""), + ("a/..", ""), + ]) + func notesFolder(path: String, category: String) { + #expect(NoteAttachmentPath.resolve(path, relativeTo: category) == nil) + } +} diff --git a/Tests/RainmakerTests/NoteAttachmentReferenceTests.swift b/Tests/RainmakerTests/NoteAttachmentReferenceTests.swift new file mode 100644 index 0000000..7075236 --- /dev/null +++ b/Tests/RainmakerTests/NoteAttachmentReferenceTests.swift @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Rainmaker +import Testing + +/// +/// About referencing an attachment from a note's Markdown content through ``NoteAttachmentReference``, the way the Nextcloud Text app writes and reads such references. +/// +/// These tests need neither a server nor response fixtures because all of its functions are pure. +/// The expected encodings equal what JavaScript's `encodeURIComponent` with `!`, `'`, `(`, `)` and `*` escaped in addition yields for each component, which is how Text encodes them. +/// +@Suite("Note Attachment Reference") struct NoteAttachmentReferenceTests { + @Test("Encodes each component of a path and decodes it again", arguments: [ + ("", ""), + (".attachments.123/Photo (1).png", ".attachments.123/Photo%20%281%29.png"), + ("a&b+c%d.png", "a%26b%2Bc%25d.png"), + ("it's!*.png", "it%27s%21%2A.png"), + ("a#b?c.png", "a%23b%3Fc.png"), + ("[draft] scan.pdf", "%5Bdraft%5D%20scan.pdf"), + ("~-_.", "~-_."), + ("Café.png", "Caf%C3%A9.png"), + ("Cafe\u{301}.png", "Cafe%CC%81.png"), + ("😀.png", "%F0%9F%98%80.png"), + ("../img/a b.png", "../img/a%20b.png"), + ("a\\b.png", "a%5Cb.png"), + ]) + func roundTrip(path: String, encoded: String) { + #expect(NoteAttachmentReference.encode(path) == encoded) + #expect(Array(NoteAttachmentReference.decode(encoded).unicodeScalars) == Array(path.unicodeScalars)) + } + + @Test("Decodes references which are not or not fully encoded", arguments: [ + ("Photo (1).png", "Photo (1).png"), + ("a+b.png", "a+b.png"), + ("100%.png", "100%.png"), + ("50%2", "50%2"), + ("%zz.png", "%zz.png"), + ("%e2%82%ac.png", "€.png"), + ("Photo%20(1).png", "Photo (1).png"), + ("%FF.png", "%FF.png"), + ("Café%20é.png", "Café é.png"), + ]) + func decodeLegacy(reference: String, expected: String) { + #expect(NoteAttachmentReference.decode(reference) == expected) + } + + @Test("Builds the Markdown of a reference", arguments: [ + ("Photo (1).png", ".attachments.123/Photo (1).png", "![Photo (1).png](.attachments.123/Photo%20%281%29.png)"), + ("[draft] scan", ".attachments.7/scan.pdf", "![draft scan](.attachments.7/scan.pdf)"), + ("two\nlines", ".attachments.7/a.png", "![two lines](.attachments.7/a.png)"), + ("", "a&b.png", "![](a%26b.png)"), + ("evil\u{202E}gnp.exe", ".attachments.1/evil\u{202E}gnp.exe", "![evilgnp.exe](.attachments.1/evilgnp.exe)"), + ("x", "\u{2066}dir\u{2069}/\u{200E}a.png", "![x](%E2%81%A6dir%E2%81%A9/a.png)"), + ]) + func markdown(alt: String, path: String, expected: String) { + #expect(NoteAttachmentReference.markdown(alt: alt, path: path) == expected) + } + + @Test("Removes the characters controlling the direction of text from a file name") + func sanitizeFileName() { + let controls = "\u{200E}\u{200F}\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{2069}" + + #expect(NoteAttachmentReference.sanitizeFileName("a" + controls + "b.png") == "ab.png") + #expect(NoteAttachmentReference.sanitizeFileName("Café (1).png") == "Café (1).png") + } +} diff --git a/Tests/RainmakerTests/NoteAttachmentRequestTests.swift b/Tests/RainmakerTests/NoteAttachmentRequestTests.swift new file mode 100644 index 0000000..76fc9cc --- /dev/null +++ b/Tests/RainmakerTests/NoteAttachmentRequestTests.swift @@ -0,0 +1,675 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About how ``Server/attachment(at:ofNote:)``, ``Server/downloadAttachment(at:ofNote:to:force:)``, both variants of ``Server/addAttachment(_:toNote:fileName:)-(URL,Int,String?)`` and ``Server/deleteAttachment(at:ofNote:)`` build their requests, and how the statuses the notes app answers them with map onto ``RainmakerError``. +/// +/// These tests deliberately do not use the fixture tree: ``URLTestSession`` ignores query strings, request headers and the bytes of an upload, so it cannot prove how the path of an attachment was encoded, which type a request accepts or what the multipart body of an upload looks like. The recorded counterparts are in ``NoteAttachmentTests``. A capturing ``MockRequesting`` is used instead. +/// +@Suite("Note Attachment Requests") struct NoteAttachmentRequestTests { + let serverAddress = URL(string: "http://localhost/")! + + /// + /// The bytes of a tiny file to attach, which do not need to be anything in particular for a mock. + /// + let bytes = Data("Rainmaker".utf8) + + /// + /// The headers every supported response of the notes app carries, which the response with the file itself lacks. + /// + let supportedHeaders = ["X-Notes-API-Versions": "0.2, 1.3, 1.4"] + + /// + /// Build a server whose session is the given mock. + /// + private func makeServer(session: any Requesting, user: String? = "admin", password: String? = "admin") -> Server { + Server(address: serverAddress, password: password, user: user, session: session, userAgent: "RainmakerTests") + } + + /// + /// A location in the temporary directory which does not exist yet. + /// + private func makeTemporaryLocation(pathExtension: String = "png") -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("NoteAttachmentRequestTests-\(UUID().uuidString).\(pathExtension)") + } + + /// + /// A new and empty folder in the temporary directory, in which a test downloads so that it can tell that nothing but the destination was left behind there. + /// + private func makeTemporaryFolder() throws -> URL { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("NoteAttachmentRequestTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: false) + return folder + } + + /// + /// The names of what the given folder contains, sorted. + /// + private func contents(of folder: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted() + } + + /// + /// Read the path of a captured request. + /// + private func path(of request: URLRequest) throws -> String { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + return components.path + } + + /// + /// Read the decoded value of the `path` parameter of a captured request. + /// + private func pathParameter(of request: URLRequest) throws -> String? { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + return components.queryItems?.first { $0.name == "path" }?.value + } + + // MARK: - Retrieving + + @Test("Retrieving Asks For Any Type Below Version 1.4") + func retrieve() async throws { + let session = MockRequesting(body: bytes, headerFields: ["Content-Type": "image/png"]) + let attachment = try await makeServer(session: session).attachment(at: ".attachments.7/Rainmaker.png", ofNote: 7) + + let request = try #require(session.requests.first) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "GET") + + // Older releases of the notes app only route attachments below the version 1.4 segment. + #expect(try path(of: request) == "/index.php/apps/notes/api/v1.4/attachment/7") + #expect(try pathParameter(of: request) == ".attachments.7/Rainmaker.png") + #expect(request.value(forHTTPHeaderField: "Accept") == "*/*") + #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Basic ") == true) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + // The response carries no version header, which is accepted here unlike for every other notes feature. + #expect(attachment == NoteAttachment(data: bytes, contentType: "image/png")) + } + + @Test("Retrieving Encodes Reserved Characters Of The Path") + func retrieveEncodesPath() async throws { + let session = MockRequesting(body: bytes, headerFields: ["Content-Type": "image/png"]) + let path = "../Ä b+c&d=e#f?.png" + _ = try await makeServer(session: session).attachment(at: path, ofNote: 7) + + let request = try #require(session.requests.first) + let query = try #require(request.url?.query) + + // Every character the server would read as something else is encoded, `+` in particular, which PHP would otherwise decode as a space. + #expect(query == "path=../%C3%84%20b%2Bc%26d%3De%23f?.png") + #expect(try pathParameter(of: request) == path) + } + + @Test("Retrieving Without A Type Assumes Bytes") + func retrieveWithoutType() async throws { + let session = MockRequesting(body: bytes) + let attachment = try await makeServer(session: session).attachment(at: "Rainmaker.png", ofNote: 7) + + #expect(attachment.contentType == "application/octet-stream") + } + + @Test("Retrieving Anything Missing Is Not Found") + func retrieveMissing() async throws { + // The notes app answers every failure with a bare not found status, and so does a server without the app. + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 404, headerFields: ["Content-Type": "application/json"]) + + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).attachment(at: "Missing.png", ofNote: 7) + } + } + + @Test("Retrieving From An Outdated App Is Reported As Such") + func retrieveFromOutdatedApp() async throws { + // A release without the version 1.4 routes sends the request to its catch-all, which answers with an error and its version header. + let session = MockRequesting(string: "[]", statusCode: 400, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + + await #expect(throws: RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"])) { + _ = try await makeServer(session: session).attachment(at: "Rainmaker.png", ofNote: 7) + } + } + + @Test("Retrieving With Another Failure Reports Its Status") + func retrieveWithOtherFailure() async throws { + let session = MockRequesting(string: "", statusCode: 500, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 500)) { + _ = try await makeServer(session: session).attachment(at: "Rainmaker.png", ofNote: 7) + } + } + + @Test("Cancelling A Retrieval Cancels Its Request") + func cancelRetrieval() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(body: bytes)) + let server = makeServer(session: session) + + let retrieval = Task { + try await server.attachment(at: "Rainmaker.png", ofNote: 7) + } + + // The request runs within the calling task, which is what a Shortcuts action relies on when it is stopped. + #expect(try await eventually { session.suspendedCount == 1 }) + retrieval.cancel() + + let result = await retrieval.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + } + + // MARK: - Downloading + + @Test("Downloading Writes The File To The Destination") + func download() async throws { + let session = MockRequesting(body: bytes, headerFields: ["Content-Type": "image/png"]) + let folder = try makeTemporaryFolder() + let destination = folder.appendingPathComponent("Rainmaker.png") + + defer { + try? FileManager.default.removeItem(at: folder) + } + + let file = try await makeServer(session: session).downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination) + let request = try #require(session.requests.first) + + #expect(file == NoteAttachmentFile(location: destination, contentType: "image/png")) + #expect(try Data(contentsOf: destination) == bytes) + #expect(try path(of: request) == "/index.php/apps/notes/api/v1.4/attachment/7") + #expect(try pathParameter(of: request) == "Rainmaker.png") + #expect(request.value(forHTTPHeaderField: "Accept") == "*/*") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + // The session's temporary file was moved on, and the staging location next to the destination is gone again. + #expect(session.downloadLocations.count == 1) + #expect(session.downloadLocations.allSatisfy { FileManager.default.fileExists(atPath: $0.path) == false }) + #expect(try contents(of: folder) == ["Rainmaker.png"]) + } + + @Test("Downloading Onto An Existing File Sends Nothing") + func downloadOntoExistingFile() async throws { + let session = MockRequesting(body: bytes, headerFields: ["Content-Type": "image/png"]) + let destination = makeTemporaryLocation() + try Data("existing".utf8).write(to: destination) + + defer { + try? FileManager.default.removeItem(at: destination) + } + + await #expect(throws: RainmakerError.fileAlreadyExists(destination)) { + _ = try await makeServer(session: session).downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination) + } + + // The check happens before the request, so a call which cannot succeed transfers nothing and leaves the file alone. + #expect(session.requests.isEmpty) + #expect(try Data(contentsOf: destination) == Data("existing".utf8)) + } + + @Test("Downloading With Force Replaces An Existing File") + func downloadWithForce() async throws { + let session = MockRequesting(body: bytes, headerFields: ["Content-Type": "image/png"]) + let destination = makeTemporaryLocation() + try Data("existing".utf8).write(to: destination) + + defer { + try? FileManager.default.removeItem(at: destination) + } + + _ = try await makeServer(session: session).downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination, force: true) + + #expect(try Data(contentsOf: destination) == bytes) + } + + @Test("Downloading Something Missing Leaves The Destination Alone") + func downloadMissing() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 404) + let folder = try makeTemporaryFolder() + let destination = folder.appendingPathComponent("Missing.png") + + defer { + try? FileManager.default.removeItem(at: folder) + } + + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).downloadAttachment(at: "Missing.png", ofNote: 7, to: destination) + } + + // The body of the error response is never put in place of the file, and neither the session's temporary file nor a staged copy is left behind. + #expect(FileManager.default.fileExists(atPath: destination.path) == false) + #expect(session.downloadLocations.count == 1) + #expect(session.downloadLocations.allSatisfy { FileManager.default.fileExists(atPath: $0.path) == false }) + #expect(try contents(of: folder).isEmpty) + } + + @Test("Downloading Refuses A File Which Appeared During The Download") + func downloadOntoFileWhichAppeared() async throws { + let folder = try makeTemporaryFolder() + let destination = folder.appendingPathComponent("Rainmaker.png") + let bytes = bytes + + defer { + try? FileManager.default.removeItem(at: folder) + } + + // The file appears while the request is answered, after the check which precedes the request passed. + let session = MockRequesting { _ in + try? Data("existing".utf8).write(to: destination) + return (bytes, 200, ["Content-Type": "image/png"]) + } + + await #expect(throws: RainmakerError.fileAlreadyExists(destination)) { + _ = try await makeServer(session: session).downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination) + } + + #expect(try Data(contentsOf: destination) == Data("existing".utf8)) + #expect(session.downloadLocations.count == 1) + #expect(session.downloadLocations.allSatisfy { FileManager.default.fileExists(atPath: $0.path) == false }) + #expect(try contents(of: folder) == ["Rainmaker.png"]) + } + + @Test("Downloading Never Replaces A Directory") + func downloadOntoDirectory() async throws { + let session = MockRequesting(body: bytes, headerFields: ["Content-Type": "image/png"]) + let destination = try makeTemporaryFolder() + let child = destination.appendingPathComponent("Important.txt") + try Data("important".utf8).write(to: child) + + defer { + try? FileManager.default.removeItem(at: destination) + } + + // Replacing a directory would delete everything in it, so it is refused even when forced, before anything is sent. + await #expect(throws: RainmakerError.fileAlreadyExists(destination)) { + _ = try await makeServer(session: session).downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination, force: true) + } + + #expect(session.requests.isEmpty) + #expect(try Data(contentsOf: child) == Data("important".utf8)) + } + + @Test("Downloading Refuses A Directory Which Appeared During The Download") + func downloadOntoDirectoryWhichAppeared() async throws { + let folder = try makeTemporaryFolder() + let destination = folder.appendingPathComponent("Rainmaker.png") + let child = destination.appendingPathComponent("Important.txt") + let bytes = bytes + + defer { + try? FileManager.default.removeItem(at: folder) + } + + // The directory appears while the request is answered, so only the check right before the file is put in place can catch it. + let session = MockRequesting { _ in + try? FileManager.default.createDirectory(at: destination, withIntermediateDirectories: false) + try? Data("important".utf8).write(to: child) + return (bytes, 200, ["Content-Type": "image/png"]) + } + + await #expect(throws: RainmakerError.fileAlreadyExists(destination)) { + _ = try await makeServer(session: session).downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination, force: true) + } + + #expect(try Data(contentsOf: child) == Data("important".utf8)) + #expect(session.downloadLocations.allSatisfy { FileManager.default.fileExists(atPath: $0.path) == false }) + #expect(try contents(of: folder) == ["Rainmaker.png"]) + } + + @Test("Cancelling A Download Cancels Its Request And Leaves No File") + func cancelDownload() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(body: bytes), suspendsDownloads: true) + let server = makeServer(session: session) + let folder = try makeTemporaryFolder() + let destination = folder.appendingPathComponent("Rainmaker.png") + + defer { + try? FileManager.default.removeItem(at: folder) + } + + let download = Task { + try await server.downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination) + } + + #expect(try await eventually { session.suspendedCount == 1 }) + download.cancel() + + let result = await download.result + + #expect(throws: URLError.self) { + try result.get() + } + + // Nothing is put in place before the download completed, so a cancelled one leaves neither the destination nor a staged copy behind. + #expect(try contents(of: folder).isEmpty) + } + + // MARK: - Adding + + @Test("Adding A File Posts It As A Form Below Version 1.4") + func addFile() async throws { + let session = MockRequesting(string: #"{"filename":".attachments.7/Rainmaker.png"}"#, headerFields: supportedHeaders) + let source = makeTemporaryLocation() + try bytes.write(to: source) + + defer { + try? FileManager.default.removeItem(at: source) + } + + let storedPath = try await makeServer(session: session).addAttachment(source, toNote: 7, fileName: "Rainmaker.png") + let request = try #require(session.requests.first) + let contentType = try #require(request.value(forHTTPHeaderField: "Content-Type")) + + #expect(storedPath == ".attachments.7/Rainmaker.png") + #expect(session.requests.count == 1) + #expect(request.httpMethod == "POST") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1.4/attachment/7") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + // The boundary the header announces is the one which delimits the single part of the body, which carries the bytes unchanged. + #expect(contentType.hasPrefix("multipart/form-data; boundary=")) + let boundary = String(contentType.dropFirst("multipart/form-data; boundary=".count)) + let expectedBody = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"Rainmaker.png\"\r\nContent-Type: application/octet-stream\r\n\r\n".utf8) + bytes + Data("\r\n--\(boundary)--\r\n".utf8) + + #expect(request.httpBody == expectedBody) + + // The staged body is removed once it has been sent, while the source is left alone. + let staged = try #require(session.uploadSources.first) + #expect(staged != source) + #expect(staged.lastPathComponent.hasSuffix(".multipart")) + #expect(FileManager.default.fileExists(atPath: staged.path) == false) + #expect(FileManager.default.fileExists(atPath: source.path)) + } + + @Test("Adding A File Names It After The Source By Default") + func addFileWithDefaultName() async throws { + let session = MockRequesting(string: #"{"filename":"0123456789abcdef0123456789abcdef.png"}"#, headerFields: supportedHeaders) + let source = makeTemporaryLocation() + try bytes.write(to: source) + + defer { + try? FileManager.default.removeItem(at: source) + } + + let storedPath = try await makeServer(session: session).addAttachment(source, toNote: 7) + let body = try #require(session.requests.first?.httpBody) + + // Older releases of the notes app store the file under a random name and return it as it is. + #expect(storedPath == "0123456789abcdef0123456789abcdef.png") + #expect(body.range(of: Data("filename=\"\(source.lastPathComponent)\"".utf8)) != nil) + } + + @Test("Adding Bytes Posts Them Under The Given Name") + func addData() async throws { + let session = MockRequesting(string: #"{"filename":".attachments.7/Rainmaker.png"}"#, headerFields: supportedHeaders) + let storedPath = try await makeServer(session: session).addAttachment(bytes, toNote: 7, fileName: "Rainmaker.png") + let request = try #require(session.requests.first) + let body = try #require(request.httpBody) + + #expect(storedPath == ".attachments.7/Rainmaker.png") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1.4/attachment/7") + #expect(body.range(of: Data("name=\"file\"; filename=\"Rainmaker.png\"\r\n".utf8)) != nil) + #expect(body.range(of: Data("\r\n\r\n".utf8) + bytes + Data("\r\n--".utf8)) != nil) + + let staged = try #require(session.uploadSources.first) + #expect(FileManager.default.fileExists(atPath: staged.path) == false) + } + + @Test("Adding Escapes The File Name") + func addEscapesFileName() async throws { + let session = MockRequesting(string: #"{"filename":"x"}"#, headerFields: supportedHeaders) + _ = try await makeServer(session: session).addAttachment(bytes, toNote: 7, fileName: "Ä \"quoted\"\r\nname.png") + let body = try #require(session.requests.first?.httpBody) + + // A quote would end the parameter and a line break the header, while everything else is sent as UTF-8. + #expect(body.range(of: Data("filename=\"Ä %22quoted%22name.png\"\r\n".utf8)) != nil) + } + + @Test("Adding Writes The File Through A Small Buffer") + func addStreamsThroughBuffer() throws { + let source = makeTemporaryLocation() + let destination = makeTemporaryLocation(pathExtension: "multipart") + let contents = Data((0 ..< 10000).map { UInt8($0 % 251) }) + try contents.write(to: source) + + defer { + try? FileManager.default.removeItem(at: source) + try? FileManager.default.removeItem(at: destination) + } + + // A buffer much smaller than the file proves that the copy loops rather than stopping after the first read. + let form = MultipartFormData(boundary: "B") + try form.writeFile(from: source, fieldName: "file", fileName: "a.bin", to: destination, bufferSize: 7) + + let expected = Data("--B\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n".utf8) + contents + Data("\r\n--B--\r\n".utf8) + + #expect(try Data(contentsOf: destination) == expected) + #expect(form.contentType == "multipart/form-data; boundary=B") + } + + @Test("Staging Stops When The Task Is Cancelled") + func stagingStopsOnCancellation() async throws { + let source = makeTemporaryLocation() + let destination = makeTemporaryLocation(pathExtension: "multipart") + try bytes.write(to: source) + + defer { + try? FileManager.default.removeItem(at: source) + try? FileManager.default.removeItem(at: destination) + } + + // The task waits for its cancellation before it stages, so the copy loop is what has to notice it. + let staging = Task { + while Task.isCancelled == false { + await Task.yield() + } + + try MultipartFormData(boundary: "B").writeFile(from: source, fieldName: "file", fileName: "a.bin", to: destination, bufferSize: 1) + } + + staging.cancel() + + let result = await staging.result + + #expect(throws: CancellationError.self) { + try result.get() + } + } + + @Test("Adding From A Cancelled Task Sends Nothing") + func addFromCancelledTask() async throws { + let session = MockRequesting(string: #"{"filename":"x"}"#, headerFields: supportedHeaders) + let server = makeServer(session: session) + let source = makeTemporaryLocation() + try bytes.write(to: source) + + defer { + try? FileManager.default.removeItem(at: source) + } + + let addition = Task { + while Task.isCancelled == false { + await Task.yield() + } + + return try await server.addAttachment(source, toNote: 7) + } + + addition.cancel() + + let result = await addition.result + + // Staging notices the cancellation, so the upload is never started. + #expect(throws: CancellationError.self) { + try result.get() + } + + #expect(session.requests.isEmpty) + } + + @Test("Adding A Missing File Sends Nothing") + func addMissingFile() async throws { + let session = MockRequesting(string: #"{"filename":"x"}"#, headerFields: supportedHeaders) + + await #expect(throws: (any Error).self) { + _ = try await makeServer(session: session).addAttachment(makeTemporaryLocation(), toNote: 7) + } + + #expect(session.requests.isEmpty) + } + + @Test("Adding To A Missing Note Is Not Found") + func addToMissingNote() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 404, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).addAttachment(bytes, toNote: 7, fileName: "Rainmaker.png") + } + } + + @Test("Adding Under A Refused Name Reports Its Status") + func addUnderRefusedName() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 400, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 400)) { + _ = try await makeServer(session: session).addAttachment(bytes, toNote: 7, fileName: ".htaccess") + } + } + + @Test("Adding Requires A Path In Response") + func addWithoutPath() async throws { + let session = MockRequesting(string: "[]", headerFields: supportedHeaders) + + await #expect { + _ = try await makeServer(session: session).addAttachment(bytes, toNote: 7, fileName: "Rainmaker.png") + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("Cancelling An Addition Cancels Its Upload And Removes The Staged Body") + func cancelAddition() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(string: #"{"filename":"x"}"#, headerFields: supportedHeaders)) + let server = makeServer(session: session) + let bytes = bytes + + let addition = Task { + try await server.addAttachment(bytes, toNote: 7, fileName: "Rainmaker.png") + } + + #expect(try await eventually { session.suspendedCount == 1 }) + + // The body is staged while the upload is in flight. + let staged = try #require(session.uploadSources.first) + #expect(FileManager.default.fileExists(atPath: staged.path)) + + addition.cancel() + + let result = await addition.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(FileManager.default.fileExists(atPath: staged.path) == false) + } + + // MARK: - Deleting + + @Test("Deleting Sends The Path Below Version 1.4") + func delete() async throws { + let session = MockRequesting(string: "[]", headerFields: supportedHeaders) + try await makeServer(session: session).deleteAttachment(at: ".attachments.7/a+b.png", ofNote: 7) + + let request = try #require(session.requests.first) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "DELETE") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1.4/attachment/7") + #expect(request.url?.query == "path=.attachments.7/a%2Bb.png") + #expect(try pathParameter(of: request) == ".attachments.7/a+b.png") + } + + @Test("Deleting On An Older App Is Not Allowed") + func deleteOnOlderApp() async throws { + // Releases before 6.1.0 route no deletion of attachments, so the server refuses the method before the app is involved. + let session = MockRequesting(string: "", statusCode: 405) + + await #expect(throws: RainmakerError.methodNotAllowed) { + try await makeServer(session: session).deleteAttachment(at: "Rainmaker.png", ofNote: 7) + } + } + + @Test("Deleting Something Missing Is Not Found") + func deleteMissing() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 404, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.notFound) { + try await makeServer(session: session).deleteAttachment(at: "Missing.png", ofNote: 7) + } + } + + @Test("Deleting From A Read-Only Note Is Refused") + func deleteFromReadOnlyNote() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 403, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.readOnly) { + try await makeServer(session: session).deleteAttachment(at: "Rainmaker.png", ofNote: 7) + } + } + + @Test("Deleting Without The App Is Reported As Such") + func deleteWithoutApp() async throws { + let session = MockRequesting(string: "", statusCode: 404) + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + try await makeServer(session: session).deleteAttachment(at: "Rainmaker.png", ofNote: 7) + } + } + + // MARK: - Credentials + + @Test("Require Credentials") + func requireCredentials() async throws { + let session = MockRequesting(body: bytes) + let server = makeServer(session: session, user: nil, password: nil) + let destination = makeTemporaryLocation() + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.attachment(at: "Rainmaker.png", ofNote: 7) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.downloadAttachment(at: "Rainmaker.png", ofNote: 7, to: destination) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.addAttachment(destination, toNote: 7) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.addAttachment(bytes, toNote: 7, fileName: "Rainmaker.png") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + try await server.deleteAttachment(at: "Rainmaker.png", ofNote: 7) + } + + // Nothing is sent without credentials. + #expect(session.requests.isEmpty) + #expect(FileManager.default.fileExists(atPath: destination.path) == false) + } +} diff --git a/Tests/RainmakerTests/NoteAttachmentTests.swift b/Tests/RainmakerTests/NoteAttachmentTests.swift new file mode 100644 index 0000000..ab81f10 --- /dev/null +++ b/Tests/RainmakerTests/NoteAttachmentTests.swift @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import RainmakerTestServerTags +import Testing + +/// +/// About adding, retrieving and deleting the attachments of notes of the authenticated user against the notes app. +/// +/// The fixtures backing this suite are recorded like those of ``NoteMutationTests``, against a container with the notes app installed. Every test creates the note it attaches a file to and deletes everything it created again, never touching the notes ``FixtureProvisioner`` seeds. +/// +/// Where an uploaded attachment lands and whether it can be deleted depends on the release of the notes app rather than on the server, so each test records the capabilities first and branches on ``Notes/storesAttachmentsPerNote`` and ``Notes/supportsAttachmentDeletion``. Releases which do not keep attachments per note store them under a random name next to the note and offer no deletion, so the tests remove such an attachment over WebDAV through the path of the note instead. +/// +/// The identifier of the note is part of the path of every request after its creation, and the path of the attachment is only sent as a query parameter, which ``FixtureLocator`` ignores, so every request of a test differs in method or path and replays from the fixture tree. How the requests are built is covered by ``NoteAttachmentRequestTests`` instead. +/// +/// Calls go through ``Serving``, whose requirements have no default arguments, so every argument is spelled out. +/// +@Suite("Note Attachments") struct NoteAttachmentTests: ServerTesting { + /// + /// The moment the notes of this suite are created with, pinned so that the recorded `modified` replays. + /// + let creation = Date(timeIntervalSince1970: 1_650_000_000) + + /// + /// The bytes of a PNG image of a single transparent pixel, which is what the suite attaches. + /// + let image = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x00, 0x02, 0x00, 0x00, 0x05, 0x00, 0x01, 0x7A, 0x5E, 0xAB, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]) + + /// + /// Check the path the server stored an attachment named `Rainmaker.png` at, which depends on the release of the notes app. + /// + /// - Parameters: + /// - path: The path ``Serving/addAttachment(_:toNote:fileName:)`` returned. + /// - note: The note the attachment was added to. + /// - notes: The capability of the notes app which answered. + /// + private func expectStoredPath(_ path: String, of note: Note, by notes: Notes) { + if notes.storesAttachmentsPerNote { + // The file keeps its name in the folder of the note's own attachments. + #expect(path == ".attachments.\(note.id)/Rainmaker.png") + } else { + // The file lands next to the note under a random name which keeps only the extension. + #expect(path.hasSuffix(".png")) + #expect(path.count == 36) + #expect(path.contains("/") == false) + } + } + + /// + /// Remove an attachment and the note it belongs to, in the way the release of the notes app allows. + /// + /// Releases which keep attachments per note delete them along with the note. Older releases leave them behind, so the attachment is deleted over WebDAV first, from the folder the note's file is in, which also lets the server remove the folder of the note's category once the note is deleted. + /// + /// - Parameters: + /// - path: The path of the attachment relative to the folder of the note's category. + /// - note: The note the attachment belongs to. + /// - notes: The capability of the notes app which answered. + /// - server: The server to remove them from. + /// + private func remove(attachmentAt path: String, of note: Note, by notes: Notes, from server: any Serving) async throws { + if notes.storesAttachmentsPerNote == false { + let notePath = try #require(note.path) + let folder = (notePath as NSString).deletingLastPathComponent + try await server.delete("\(folder)/\(path)") + } + + try await server.deleteNote(note.id) + } + + @Test("Lifecycle", arguments: ServerVersion.allCases) + func lifecycle(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let notes = try #require(try await server.capabilities().get(Notes.self)) + let note = try await server.createNote(title: "Rainmaker Attachment Test", category: "Rainmaker Tests", content: "# Test\n", modification: creation, isFavorite: false) + let path = try await server.addAttachment(image, toNote: note.id, fileName: "Rainmaker.png") + + expectStoredPath(path, of: note, by: notes) + + // The path is relative to the folder of the note's category, which is what the retrieval resolves it against, and the type is derived from the extension. + let attachment = try await server.attachment(at: path, ofNote: note.id) + + #expect(attachment.data == image) + #expect(attachment.contentType == "image/png") + + if notes.supportsAttachmentDeletion { + // Deleting the attachment also removes its folder once it is empty, which the deletion of the note would otherwise take care of. + try await server.deleteAttachment(at: path, ofNote: note.id) + try await server.deleteNote(note.id) + } else { + // Older releases route no deletion of attachments, which the server refuses before the app is involved. + await #expect(throws: RainmakerError.methodNotAllowed) { + try await server.deleteAttachment(at: path, ofNote: note.id) + } + + try await remove(attachmentAt: path, of: note, by: notes, from: server) + } + } + + @Test("Download", arguments: ServerVersion.allCases) + func download(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let notes = try #require(try await server.capabilities().get(Notes.self)) + let note = try await server.createNote(title: "Rainmaker Attachment Test", category: "Rainmaker Tests", content: "# Test\n", modification: creation, isFavorite: false) + let path = try await server.addAttachment(image, toNote: note.id, fileName: "Rainmaker.png") + + expectStoredPath(path, of: note, by: notes) + + let destination = FileManager.default.temporaryDirectory.appendingPathComponent("NoteAttachmentTests-\(UUID().uuidString).png") + + defer { + try? FileManager.default.removeItem(at: destination) + } + + // The file is streamed to disk rather than handed over in memory, and arrives with the same bytes and type. + let file = try await server.downloadAttachment(at: path, ofNote: note.id, to: destination, force: false) + + #expect(file.location == destination) + #expect(file.contentType == "image/png") + #expect(try Data(contentsOf: destination) == image) + + try await remove(attachmentAt: path, of: note, by: notes, from: server) + } + + @Test("Missing Attachment", arguments: ServerVersion.allCases) + func missingAttachment(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let note = try await server.createNote(title: "Rainmaker Attachment Test", category: "", content: "# Test\n", modification: creation, isFavorite: false) + + // Every release answers a path which names nothing with a bare not found status. + await #expect(throws: RainmakerError.notFound) { + _ = try await server.attachment(at: "Missing.png", ofNote: note.id) + } + + try await server.deleteNote(note.id) + } +} diff --git a/Tests/RainmakerTests/NoteCategoryTests.swift b/Tests/RainmakerTests/NoteCategoryTests.swift new file mode 100644 index 0000000..c41da1d --- /dev/null +++ b/Tests/RainmakerTests/NoteCategoryTests.swift @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Rainmaker +import Testing + +/// +/// About predicting the ``Note/category`` the notes app files a note under through ``NoteCategory/sanitize(_:)``. +/// +/// These tests need neither a server nor response fixtures because the function is pure. +/// The expected categories were checked against the behaviour of the notes app's own category handling, and they are compared scalar by scalar, so that a decomposed letter does not pass for its composed form. +/// +@Suite("Note Category") struct NoteCategoryTests { + @Test("Sanitizes a category component by component", arguments: [ + ("", ""), + ("Work", "Work"), + ("Work/Projects", "Work/Projects"), + ("/Work//Projects/", "Work/Projects"), + (" Work / Projects ", "Work/Projects"), + ("Work/../Private", "Work/Private"), + ("..", ""), + ("../..", ""), + ("../etc/passwd", "etc/passwd"), + (".hidden/x", "hidden/x"), + ("\u{A0}Work", "Work"), + ("Work\u{A0}/x", "Work\u{A0}/x"), + ("a\\b", "ab"), + ("C:/x", "C/x"), + ("What?/Why*", "What/Why"), + ("Fish & Chips/1+1/100%", "Fish & Chips/1+1/100%"), + ("Reports (1)", "Reports (1)"), + ("Café/e\u{301}", "Café/e\u{301}"), + ("😀/x", "😀/x"), + ("# Heading/_em_", "# Heading/_em_"), + ("0", "0"), + ("Line one\nLine two", "Line one\nLine two"), + ]) + func sanitize(category: String, expected: String) { + #expect(Array(NoteCategory.sanitize(category).unicodeScalars) == Array(expected.unicodeScalars)) + } + + @Test("Does not cut a component off") + func noCutOff() { + // Unlike a title, a category component is neither cut to its first line nor to a number of characters. + let component = String(repeating: "a", count: 150) + + #expect(NoteCategory.sanitize(component + "/" + component) == component + "/" + component) + } +} diff --git a/Tests/RainmakerTests/NoteChangesRequestTests.swift b/Tests/RainmakerTests/NoteChangesRequestTests.swift new file mode 100644 index 0000000..3d70e25 --- /dev/null +++ b/Tests/RainmakerTests/NoteChangesRequestTests.swift @@ -0,0 +1,527 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About what ``Server/notes(changedSince:)`` and ``Server/notes(changedSince:ifChangedFrom:)`` read from the headers of a response, and how the latter asks for and reports a response which did not change. +/// +/// These tests deliberately do not use the fixture tree, for the reasons ``NotesRequestTests`` gives: the canonicalized fixtures carry fixed header values only, ``URLTestSession`` ignores request headers, so it cannot prove a conditional request was made, and a test sending the same request twice would find the same fixture both times. A capturing ``MockRequesting`` is used instead. +/// +@Suite("Note Changes Requests") struct NoteChangesRequestTests { + let serverAddress = URL(string: "http://localhost/")! + + /// + /// A moment the requests in this suite ask for changes since. + /// + let changedSince = Date(timeIntervalSince1970: 1_700_000_000) + + /// + /// A response body with one note sent in full and one reduced to its identifier, which is the shape every listing narrowed down by a moment has. + /// + let payload = #"[{"id":7,"etag":"9cf1","readonly":false,"modified":1700000000,"title":"Changed","category":"","content":"text","favorite":false,"error":false,"errorType":""},{"id":8}]"# + + /// + /// The headers of a complete listing as the notes app sends them, with the version header every supported response has to carry. + /// + let completeHeaders = [ + "X-Notes-API-Versions": "0.2, 1.3, 1.4", + "ETag": #""c649e503de046daca1b998c2e52b2a94""#, + "Last-Modified": "Tue, 14 Nov 2023 22:13:20 GMT", + ] + + /// + /// Build a server whose session is the given mock. + /// + private func makeServer(session: MockRequesting) -> Server { + Server(address: serverAddress, password: "admin", user: "admin", session: session, userAgent: "RainmakerTests") + } + + // MARK: - Headers + + @Test("Reads The Moment And The Entity Tag From The Headers") + func readsHeaders() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let changes = try await makeServer(session: session).notes(changedSince: changedSince) + + #expect(changes.changed.map(\.id) == [7]) + #expect(changes.unchanged == [8]) + + // The moment is the server's own time it started answering, which is what the next call passes back as its moment. + #expect(changes.lastModified == Date(timeIntervalSince1970: 1_700_000_000)) + + // The tag is handed out bare, so it can be passed back without knowing how a header has to quote it. + #expect(changes.entityTag == "c649e503de046daca1b998c2e52b2a94") + + // A response which was not split into chunks is complete and lists every note the account has. + #expect(changes.chunkCursor == nil) + #expect(changes.pendingCount == nil) + #expect(changes.isComplete) + } + + @Test("Reads A Weakened Entity Tag Without Its Marker") + func readsWeakEntityTag() async throws { + var headers = completeHeaders + headers["ETag"] = #"W/"c649e503de046daca1b998c2e52b2a94""# + + let changes = try await makeServer(session: MockRequesting(string: payload, headerFields: headers)).notes(changedSince: changedSince) + + // A proxy compressing the response may weaken the tag, but the server only compares the bare value. + #expect(changes.entityTag == "c649e503de046daca1b998c2e52b2a94") + } + + @Test("Reads The Chunk Cursor And The Pending Count") + func readsChunkHeaders() async throws { + var headers = completeHeaders + headers["X-Notes-Chunk-Cursor"] = "1700000000-1699999999-7" + headers["X-Notes-Chunk-Pending"] = "3" + + let changes = try await makeServer(session: MockRequesting(string: payload, headerFields: headers)).notes(changedSince: changedSince) + + // A chunk which is not the last one does not list every note, so deletions must not be derived from it. + #expect(changes.chunkCursor == "1700000000-1699999999-7") + #expect(changes.pendingCount == 3) + #expect(changes.isComplete == false) + } + + @Test("Absent Or Unreadable Headers Leave Their Values Out") + func absentHeaders() async throws { + let headers = ["X-Notes-API-Versions": "0.2, 1.3, 1.4", "Last-Modified": "yesterday", "X-Notes-Chunk-Pending": "many"] + let changes = try await makeServer(session: MockRequesting(string: payload, headerFields: headers)).notes(changedSince: changedSince) + + // The notes themselves are what a caller cannot do without, so a header it cannot read does not fail the retrieval. + #expect(changes.changed.count == 1) + #expect(changes.lastModified == nil) + #expect(changes.entityTag == nil) + #expect(changes.pendingCount == nil) + #expect(changes.isComplete) + } + + @Test("An Unconditional Request Sends No Entity Tag") + func unconditionalRequest() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + _ = try await makeServer(session: session).notes(changedSince: changedSince) + + let request = try #require(session.requests.first) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + } + + // MARK: - Conditional Request + + @Test("Sends The Entity Tag Quoted", arguments: [ + "c649e503de046daca1b998c2e52b2a94", + #""c649e503de046daca1b998c2e52b2a94""#, + #"W/"c649e503de046daca1b998c2e52b2a94""#, + ]) + func sendsQuotedEntityTag(_ entityTag: String) async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + _ = try await makeServer(session: session).notes(changedSince: changedSince, ifChangedFrom: entityTag) + + let request = try #require(session.requests.first) + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + // The server only answers with not modified when the header repeats its strong tag quoted and exactly, so every shape a caller may hold is normalized to that. + #expect(request.value(forHTTPHeaderField: "If-None-Match") == #""c649e503de046daca1b998c2e52b2a94""#) + #expect(request.httpMethod == "GET") + #expect(components.path == "/index.php/apps/notes/api/v1/notes") + #expect(components.queryItems == [URLQueryItem(name: "pruneBefore", value: "1700000000")]) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + } + + @Test("Not Modified Is Reported As Nil") + func notModified() async throws { + // The server answers a matching tag with an empty body and still sends its version header, because the notes app adds it before the server turns the status into not modified. + let session = MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + let changes = try await makeServer(session: session).notes(changedSince: changedSince, ifChangedFrom: "c649e503de046daca1b998c2e52b2a94") + + // Nil rather than empty changes, which would claim that every note was deleted. + #expect(changes == nil) + } + + @Test("A Changed Response Is Returned In Full") + func changedResponse() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let changes = try #require(try await makeServer(session: session).notes(changedSince: changedSince, ifChangedFrom: "stale")) + + #expect(changes.changed.map(\.id) == [7]) + #expect(changes.unchanged == [8]) + #expect(changes.lastModified == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(changes.entityTag == "c649e503de046daca1b998c2e52b2a94") + } + + @Test("Polling With The Previous Tag Returns Nil Until Something Changes") + func pollingSequence() async throws { + let currentTag = #""c649e503de046daca1b998c2e52b2a94""# + let headerFields = completeHeaders + + // Mirrors how the server decides: the full response when the tag differs from the current one, not modified when it matches. + let session = MockRequesting { request in + if request.value(forHTTPHeaderField: "If-None-Match") == currentTag { + return (Data(), 304, ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + } + + return (Data(#"[{"id":7},{"id":8}]"#.utf8), 200, headerFields) + } + + let server = makeServer(session: session) + let first = try await server.notes(changedSince: changedSince) + let entityTag = try #require(first.entityTag) + let lastModified = try #require(first.lastModified) + + // The server compares the tag against the body it would send rather than against the moment, so passing on both values of the previous result is what a polling client does. + let second = try await server.notes(changedSince: lastModified, ifChangedFrom: entityTag) + + #expect(first.unchanged == [7, 8]) + #expect(second == nil) + #expect(session.requests.count == 2) + } + + @Test("Not Modified Is Unexpected For An Unconditional Request") + func notModifiedUnconditionally() async throws { + let server = makeServer(session: MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"])) + + // Without a tag the server has no reason to answer not modified, so such an answer is not something to be passed off as either empty or absent changes. + await #expect(throws: RainmakerError.unexpectedStatus(code: 304)) { + _ = try await server.notes(changedSince: changedSince) + } + } + + @Test("The Conditional Request Maps Statuses Like The Unconditional One") + func conditionalStatusMapping() async throws { + let unavailable = makeServer(session: MockRequesting(string: "", statusCode: 404, headerFields: [:])) + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await unavailable.notes(changedSince: changedSince, ifChangedFrom: "c649e503") + } + + let outdated = makeServer(session: MockRequesting(string: "[]", headerFields: ["X-Notes-API-Versions": "0.2, 1.3"])) + let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"]) + + await #expect(throws: expected) { + _ = try await outdated.notes(changedSince: changedSince, ifChangedFrom: "c649e503") + } + } + + @Test("The Conditional Request Requires Credentials") + func conditionalRequiresCredentials() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let server = Server(address: serverAddress, password: nil, user: nil, session: session, userAgent: "RainmakerTests") + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.notes(changedSince: changedSince, ifChangedFrom: "c649e503") + } + + #expect(session.requests.isEmpty) + } + + // MARK: - Chunks + + /// + /// Read the query items of a captured request, which is where the chunk parameters travel. + /// + private func queryItems(of request: URLRequest) throws -> [URLQueryItem] { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + return components.queryItems ?? [] + } + + /// + /// Read the value of one query item of a captured request, or `nil` when it was not sent. + /// + private func queryValue(_ name: String, of request: URLRequest) -> String? { + let components = request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) } + + return components?.queryItems?.first { $0.name == name }?.value + } + + /// + /// Build a responder which answers a pass of three chunks the way the notes app does, keyed on the cursor each request continues from. + /// + /// The first two chunks carry one note in full each, a cursor and the number of notes still pending, while the last one carries the third note in full and the identifiers of all the others, those sent by the earlier chunks included. + /// + private func makeThreeChunkResponder(failingAt failingCursor: String? = nil) -> MockRequesting.Responder { + let headers = completeHeaders + + return { request in + let components = request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) } + let cursor = components?.queryItems?.first { $0.name == "chunkCursor" }?.value + + if let failingCursor, cursor == failingCursor { + return (Data(#"{"errorType":"Exception"}"#.utf8), 500, ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + } + + var chunkHeaders = headers + let body: String + + switch cursor { + case nil: + body = #"[{"id":1,"etag":"a1","readonly":false,"modified":1700000000,"title":"One","category":"","content":"1","favorite":false,"error":false,"errorType":""}]"# + chunkHeaders["X-Notes-Chunk-Cursor"] = "1700000000-1699999001-1" + chunkHeaders["X-Notes-Chunk-Pending"] = "2" + + case "1700000000-1699999001-1": + body = #"[{"id":2,"etag":"a2","readonly":false,"modified":1700000000,"title":"Two","category":"","content":"2","favorite":false,"error":false,"errorType":""}]"# + chunkHeaders["X-Notes-Chunk-Cursor"] = "1700000000-1699999002-2" + chunkHeaders["X-Notes-Chunk-Pending"] = "1" + + default: + body = #"[{"id":3,"etag":"a3","readonly":false,"modified":1700000000,"title":"Three","category":"","content":"3","favorite":false,"error":false,"errorType":""},{"id":1},{"id":2},{"id":4}]"# + } + + return (Data(body.utf8), 200, chunkHeaders) + } + } + + @Test("The First Chunk Sends The Moment And The Chunk Size Only") + func firstChunkQuery() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + _ = try await makeServer(session: session).notes(changedSince: changedSince, chunkSize: 25) + + let request = try #require(session.requests.first) + let url = try #require(request.url) + + // A new pass has nothing to continue from, and a category is never asked for because it would also narrow the identifiers deletions are derived from. + #expect(try queryItems(of: request) == [URLQueryItem(name: "pruneBefore", value: "1700000000"), URLQueryItem(name: "chunkSize", value: "25")]) + #expect(url.path == "/index.php/apps/notes/api/v1/notes") + #expect(request.httpMethod == "GET") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + } + + @Test("A Following Chunk Sends The Cursor") + func followingChunkQuery() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + _ = try await makeServer(session: session).notes(changedSince: changedSince, chunkSize: 25, continuingAfter: "1700000000-1699999999-7") + + let request = try #require(session.requests.first) + + // The cursor is handed back exactly as the server sent it, along with the same moment the pass started with. + #expect(try queryItems(of: request) == [URLQueryItem(name: "pruneBefore", value: "1700000000"), URLQueryItem(name: "chunkSize", value: "25"), URLQueryItem(name: "chunkCursor", value: "1700000000-1699999999-7")]) + } + + @Test("The Chunk Size Is At Least One", arguments: [(0, "1"), (-5, "1"), (Int.min, "1"), (1, "1"), (200, "200")]) + func chunkSizeClamping(_ requested: Int, _ sent: String) async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let server = makeServer(session: session) + + _ = try await server.notes(changedSince: changedSince, chunkSize: requested) + _ = try await server.notes(changedSince: changedSince, chunkSize: requested, ifChangedFrom: "c649e503") + + // The server takes zero as a request not to split the response at all, which a caller asking for chunks did not ask for. + #expect(session.requests.count == 2) + #expect(session.requests.allSatisfy { queryValue("chunkSize", of: $0) == sent }) + } + + @Test("An Unchunked Request Sends No Chunk Parameters") + func unchunkedQuery() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + _ = try await makeServer(session: session).notes(changedSince: changedSince) + + let request = try #require(session.requests.first) + #expect(try queryItems(of: request) == [URLQueryItem(name: "pruneBefore", value: "1700000000")]) + } + + @Test("The Conditional First Chunk Sends The Entity Tag Quoted And No Cursor") + func conditionalFirstChunk() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let changes = try await makeServer(session: session).notes(changedSince: changedSince, chunkSize: 10, ifChangedFrom: #"W/"c649e503de046daca1b998c2e52b2a94""#) + + let request = try #require(session.requests.first) + + #expect(request.value(forHTTPHeaderField: "If-None-Match") == #""c649e503de046daca1b998c2e52b2a94""#) + #expect(try queryItems(of: request) == [URLQueryItem(name: "pruneBefore", value: "1700000000"), URLQueryItem(name: "chunkSize", value: "10")]) + #expect(changes?.changed.map(\.id) == [7]) + } + + @Test("The Conditional First Chunk Reports Not Modified As Nil") + func conditionalFirstChunkNotModified() async throws { + let session = MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + let changes = try await makeServer(session: session).notes(changedSince: changedSince, chunkSize: 10, ifChangedFrom: "c649e503de046daca1b998c2e52b2a94") + + #expect(changes == nil) + } + + @Test("Not Modified Is Unexpected For An Unconditional Chunk") + func notModifiedChunk() async throws { + let server = makeServer(session: MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"])) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 304)) { + _ = try await server.notes(changedSince: changedSince, chunkSize: 10, continuingAfter: "1700000000-1699999999-7") + } + } + + @Test("Chunks Require Credentials") + func chunksRequireCredentials() async throws { + let session = MockRequesting(responder: makeThreeChunkResponder()) + let server = Server(address: serverAddress, password: nil, user: nil, session: session, userAgent: "RainmakerTests") + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.notes(changedSince: changedSince, chunkSize: 1) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.notes(changedSince: changedSince, chunkSize: 1, ifChangedFrom: "c649e503") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + for try await _ in server.noteChunks(changedSince: changedSince, chunkSize: 1) {} + } + + #expect(session.requests.isEmpty) + } + + // MARK: - Stream Of Chunks + + @Test("The Stream Follows The Cursors Until The Last Chunk") + func streamFollowsCursors() async throws { + let session = MockRequesting(responder: makeThreeChunkResponder()) + var chunks = [NoteChanges]() + + for try await chunk in makeServer(session: session).noteChunks(changedSince: changedSince, chunkSize: 1) { + chunks.append(chunk) + } + + // One element per chunk, in the order the server sent them, finishing after the one which lists every note. + #expect(chunks.map { $0.changed.map(\.id) } == [[1], [2], [3]]) + #expect(chunks.map(\.isComplete) == [false, false, true]) + #expect(chunks.map(\.pendingCount) == [2, 1, nil]) + + // Only the last chunk carries the identifiers, and those of the notes the earlier chunks sent are among them. + #expect(chunks.map(\.unchanged) == [[], [], [1, 2, 4]]) + + // Every request continues from the cursor of the chunk before, with the same moment and chunk size, and none asks for a category. + let requests = session.requests + #expect(requests.map { queryValue("chunkCursor", of: $0) } == [nil, "1700000000-1699999001-1", "1700000000-1699999002-2"]) + #expect(requests.allSatisfy { queryValue("pruneBefore", of: $0) == "1700000000" }) + #expect(requests.allSatisfy { queryValue("chunkSize", of: $0) == "1" }) + #expect(requests.allSatisfy { queryValue("category", of: $0) == nil }) + } + + @Test("The Stream Of A Single Chunk Finishes After It") + func streamSingleChunk() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + var chunks = [NoteChanges]() + + for try await chunk in makeServer(session: session).noteChunks(changedSince: changedSince, chunkSize: 100) { + chunks.append(chunk) + } + + // A pass whose changes fit into one chunk consists of nothing but the complete one. + #expect(chunks.count == 1) + #expect(chunks.first?.isComplete == true) + #expect(session.requests.count == 1) + } + + @Test("The Stream Finishes By Throwing An Error Mid-Pass") + func streamErrorMidPass() async throws { + let session = MockRequesting(responder: makeThreeChunkResponder(failingAt: "1700000000-1699999001-1")) + let chunks = LockedValue([NoteChanges]()) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 500)) { + for try await chunk in makeServer(session: session).noteChunks(changedSince: changedSince, chunkSize: 1) { + chunks.withValue { $0.append(chunk) } + } + } + + // The chunk received before the error stays usable, and its cursor is what continues the pass later. + #expect(chunks.get().map(\.chunkCursor) == ["1700000000-1699999001-1"]) + #expect(session.requests.count == 2) + } + + @Test("The Stream Requests Nothing After The Consumer Stops") + func streamStopsWithConsumer() async throws { + let session = MockRequesting(responder: makeThreeChunkResponder()) + + for try await chunk in makeServer(session: session).noteChunks(changedSince: changedSince, chunkSize: 1) { + #expect(chunk.isComplete == false) + break + } + + // The next chunk is only requested when the consumer asks for it, so leaving the loop leaves the remaining chunks unrequested. + #expect(session.requests.count == 1) + } + + @Test("The Stream Requests Nothing After Its Task Was Cancelled") + func streamStopsWhenCancelled() async { + let session = MockRequesting(responder: makeThreeChunkResponder()) + let server = makeServer(session: session) + + let consumer = Task { + var count = 0 + + for try await _ in server.noteChunks(changedSince: changedSince, chunkSize: 1) { + count += 1 + withUnsafeCurrentTask { $0?.cancel() } + } + + return count + } + + // A cancellation between two chunks ends the stream quietly, which is why a consumer checks whether the last chunk is complete, and it must not request another chunk either way. + let result = await consumer.result + + if case let .failure(error) = result { + #expect(error is CancellationError) + } + + #expect(session.requests.count == 1) + } + + @Test("Cancelling The Stream's Task Cancels The Request In Flight") + func streamCancelsRequestInFlight() async throws { + let session = SuspendingRequesting(answering: 1, through: MockRequesting(responder: makeThreeChunkResponder())) + let server = Server(address: serverAddress, password: "admin", user: "admin", session: session, userAgent: "RainmakerTests") + let received = LockedValue(0) + + let consumer = Task { + for try await _ in server.noteChunks(changedSince: changedSince, chunkSize: 1) { + received.withValue { $0 += 1 } + } + } + + // The first chunk is answered, while the request for the second one stays in flight until its task is cancelled. + #expect(try await eventually { session.suspendedCount == 1 }) + consumer.cancel() + + // The request runs within the consumer's task, so cancelling that task ends the request, which then ends the stream. + let result = await consumer.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + #expect(received.get() == 1) + #expect(session.receivedCount == 2) + } + + @Test("The Stream Stops When The Cursor Does Not Advance") + func streamStopsOnRepeatedCursor() async throws { + var headers = completeHeaders + headers["X-Notes-Chunk-Cursor"] = "1700000000-1699999001-1" + headers["X-Notes-Chunk-Pending"] = "2" + + // A server which ignores the cursor answers every request with the first chunk again, which would never end. + let session = MockRequesting(string: payload, headerFields: headers) + let chunks = LockedValue(0) + + await #expect { + for try await _ in makeServer(session: session).noteChunks(changedSince: changedSince, chunkSize: 1) { + chunks.withValue { $0 += 1 } + } + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + + #expect(chunks.get() == 1) + #expect(session.requests.count == 2) + } +} diff --git a/Tests/RainmakerTests/NoteMutationTests.swift b/Tests/RainmakerTests/NoteMutationTests.swift new file mode 100644 index 0000000..835099f --- /dev/null +++ b/Tests/RainmakerTests/NoteMutationTests.swift @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import RainmakerTestServerTags +import Testing + +/// +/// About creating, changing and deleting notes of the authenticated user against the notes app. +/// +/// The fixtures backing this suite are recorded like those of ``NotesTests``, against a container with the notes app installed. Every test deletes the notes it creates, changes the settings of the notes app only to values they already have, and never changes the notes ``FixtureProvisioner`` seeds, because the baseline reset between recordings removes what a test leaves behind but does not restore a seeded note which is newer on the server than in the baseline. +/// +/// The requests of one test differ in method or path, which is what lets the fixture tree replay them: the identifier the server assigned is part of the path of every request after the creation, and it is taken from the recorded creation rather than pinned. Moments are pinned so that the `modified` the server reports replays as well. How the requests are built and how the statuses the notes app may answer with map onto ``RainmakerError`` is covered by ``NoteWritingRequestTests`` instead. +/// +/// Calls go through ``Serving``, whose requirements have no default arguments, so every argument is spelled out. +/// +@Suite("Note Mutations") struct NoteMutationTests: ServerTesting { + /// + /// The moment the notes of this suite are created with, pinned so that the recorded `modified` replays. + /// + let creation = Date(timeIntervalSince1970: 1_650_000_000) + + /// + /// The moment the notes of this suite are changed with, a little after ``creation``. + /// + let change = Date(timeIntervalSince1970: 1_650_000_100) + + @Test("Require Credentials", arguments: ServerVersion.allCases) + func requireCredentials(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion) + + // Credentials are required, so every call fails before any network request is made. + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.createNote(title: "Rainmaker Test", category: "", content: "", modification: nil, isFavorite: false) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.updateNote(1, title: nil, category: nil, content: "", modification: nil, isFavorite: nil, ifMatching: nil) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + try await server.deleteNote(1) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.updateNotesSettings(notesPath: nil, fileSuffix: ".md", noteMode: nil, showsHiddenFiles: nil, loadsRecentNoteOnStartUp: nil) + } + } + + @Test("Lifecycle", arguments: ServerVersion.allCases) + func lifecycle(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let created = try await server.createNote(title: "Rainmaker Test", category: "Rainmaker Tests", content: "# Test\n", modification: creation, isFavorite: false) + + // The server stores what it was given, and the moment is stamped onto the file after the content was written, so it is kept. + #expect(created.title == "Rainmaker Test") + #expect(created.category == "Rainmaker Tests") + #expect(created.content == "# Test\n") + #expect(created.modification == creation) + #expect(created.isFavorite == false) + #expect(created.isReadOnly == false) + #expect(created.hasError == false) + #expect(created.entityTag.isEmpty == false) + + // The category becomes a folder inside the notes folder, whose name depends on the container's locale. + #expect(created.path?.hasSuffix("/Rainmaker Tests/Rainmaker Test.md") == true) + + // The tag of the note just created is current, so the change based on it applies. + let changed = try await server.updateNote(created.id, title: nil, category: nil, content: "# Changed\n", modification: change, isFavorite: nil, ifMatching: created.entityTag) + + #expect(changed.id == created.id) + #expect(changed.content == "# Changed\n") + #expect(changed.modification == change) + #expect(changed.title == created.title) + #expect(changed.category == created.category) + + try await server.deleteNote(created.id) + + // The deleted note is gone, which a lookup reports as not found rather than as an unavailable app. + await #expect(throws: RainmakerError.notFound) { + _ = try await server.note(created.id) + } + } + + @Test("Conflict", arguments: ServerVersion.allCases) + func conflict(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let created = try await server.createNote(title: "Rainmaker Test", category: "", content: "# Test\n", modification: creation, isFavorite: false) + + // A tag the note never had does not match, so the server changes nothing and sends the note as it is now. + await #expect { + _ = try await server.updateNote(created.id, title: nil, category: nil, content: "# Changed\n", modification: change, isFavorite: nil, ifMatching: "stale") + } throws: { error in + guard case let RainmakerError.noteConflict(current: current) = error else { + return false + } + + return current.id == created.id && current.content == "# Test\n" && current.modification == creation && current.entityTag.isEmpty == false + } + + try await server.deleteNote(created.id) + } + + @Test("Favorite", arguments: ServerVersion.allCases) + func favorite(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let created = try await server.createNote(title: "Rainmaker Test", category: "", content: "# Test\n", modification: creation, isFavorite: false) + let changed = try await server.updateNote(created.id, title: nil, category: nil, content: nil, modification: nil, isFavorite: true, ifMatching: nil) + + // Only the favorite changed, which the server keeps apart from the note's file, so the content and the moment stay as they were. + #expect(changed.id == created.id) + #expect(changed.isFavorite) + #expect(changed.content == "# Test\n") + #expect(changed.modification == creation) + + try await server.deleteNote(created.id) + } + + @Test("Rename", arguments: ServerVersion.allCases) + func rename(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let created = try await server.createNote(title: "Rainmaker Test", category: "", content: "# Test\n", modification: creation, isFavorite: false) + let renamed = try await server.updateNote(created.id, title: "Rainmaker Renamed", category: "Rainmaker Tests", content: nil, modification: nil, isFavorite: nil, ifMatching: nil) + + // Renaming and moving a note moves its file, but the note keeps its identifier and content. + #expect(renamed.id == created.id) + #expect(renamed.title == "Rainmaker Renamed") + #expect(renamed.category == "Rainmaker Tests") + #expect(renamed.content == "# Test\n") + #expect(renamed.path?.hasSuffix("/Rainmaker Tests/Rainmaker Renamed.md") == true) + + try await server.deleteNote(created.id) + } + + @Test("Sanitized Title", arguments: ServerVersion.allCases) + func sanitizedTitle(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let created = try await server.createNote(title: "A/B:C", category: "", content: "# Test\n", modification: creation, isFavorite: false) + + // The title becomes a file name, so the server removes what cannot be part of one, and the caller adopts the title it returns. + #expect(created.title != "A/B:C") + #expect(created.title == "ABC") + #expect(created.path?.hasSuffix("/ABC.md") == true) + + try await server.deleteNote(created.id) + } + + @Test("Update Settings", arguments: ServerVersion.allCases) + func updateSettings(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let before = try await server.notesSettings() + + // The suffix is set to the value the baseline already has, so the account's settings are the same after the recording as before, which keeps every other suite's recordings independent of this one. + let updated = try await server.updateNotesSettings(notesPath: nil, fileSuffix: ".md", noteMode: nil, showsHiddenFiles: nil, loadsRecentNoteOnStartUp: nil) + + #expect(updated.fileSuffix == ".md") + + // Settings which were not given are left out of the request rather than sent as null, which would reset them, so they come back exactly as they were, including the folder derived from the container's locale. + #expect(updated == before) + #expect(updated.notesPath.isEmpty == false) + } + + @Test("Delete Missing", arguments: ServerVersion.allCases) + func deleteMissing(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + + // No deployment assigns an identifier this large to a baseline with two notes, so the notes app itself answers that the note does not exist, which a caller takes as the note being gone already. + await #expect(throws: RainmakerError.notFound) { + try await server.deleteNote(999_999_999) + } + } +} diff --git a/Tests/RainmakerTests/NoteSummaryRequestTests.swift b/Tests/RainmakerTests/NoteSummaryRequestTests.swift new file mode 100644 index 0000000..d73d65c --- /dev/null +++ b/Tests/RainmakerTests/NoteSummaryRequestTests.swift @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About ``Server/noteSummaries(changedSince:)``, its conditional and chunked counterparts and ``Server/noteSummaryChunks(changedSince:chunkSize:)``, which list notes without their text. +/// +/// These tests drive a capturing ``MockRequesting`` rather than the fixture tree for the reasons ``NoteChangesRequestTests`` gives: a replayed fixture neither proves which query items and request headers were sent, because ``FixtureLocator`` ignores both, nor answers a loop of chunks which share method and path. The recorded counterparts are ``NotesTests/fetchSummaries(_:)`` and ``NotesTests/fetchFirstSummaryChunk(_:)``. +/// +@Suite("Note Summary Requests") struct NoteSummaryRequestTests { + /// + /// The address of the server the requests in this suite are sent to. + /// + let serverAddress = URL(string: "http://localhost/")! + + /// + /// A moment the requests in this suite ask for changes since. + /// + let changedSince = Date(timeIntervalSince1970: 1_700_000_000) + + /// + /// A response body with one note sent in full but for its content and one reduced to its identifier, shaped as the notes app answers a listing which excludes the content. + /// + /// The server still sends `error` and `errorType`, always as `false` and empty, because it only notices an error while reading the content it was asked to leave out. + /// + let payload = #"[{"id":7,"title":"Changed","modified":1700000000,"category":"Work","favorite":true,"readonly":false,"internalPath":"/Notes/Work/Changed.md","shareTypes":[3],"isShared":true,"error":false,"errorType":"","etag":"9cf1"},{"id":8}]"# + + /// + /// The headers of a complete listing as the notes app sends them, with the version header every supported response has to carry. + /// + let completeHeaders = [ + "X-Notes-API-Versions": "0.2, 1.3, 1.4", + "ETag": #""c649e503de046daca1b998c2e52b2a94""#, + "Last-Modified": "Tue, 14 Nov 2023 22:13:20 GMT", + ] + + /// + /// The query item every summary listing has to send. + /// + let excludeContent = URLQueryItem(name: "exclude", value: "content") + + /// + /// The query item every listing in this suite sends for ``changedSince``. + /// + let pruneBefore = URLQueryItem(name: "pruneBefore", value: "1700000000") + + /// + /// Build a server whose session is the given mock. + /// + private func makeServer(session: any Requesting) -> Server { + Server(address: serverAddress, password: "admin", user: "admin", session: session, userAgent: "RainmakerTests") + } + + /// + /// Read the query items of a captured request. + /// + private func queryItems(of request: URLRequest) throws -> [URLQueryItem] { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + return components.queryItems ?? [] + } + + /// + /// Build a responder which answers a pass of two chunks of summaries, keyed on the cursor each request continues from. + /// + private func makeTwoChunkResponder() -> MockRequesting.Responder { + let headers = completeHeaders + + return { request in + let components = request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) } + let cursor = components?.queryItems?.first { $0.name == "chunkCursor" }?.value + var chunkHeaders = headers + let body: String + + if cursor == nil { + body = #"[{"id":1,"etag":"a1","readonly":false,"modified":1700000000,"title":"One","category":"","favorite":false,"error":false,"errorType":""}]"# + chunkHeaders["X-Notes-Chunk-Cursor"] = "1700000000-1699999001-1" + chunkHeaders["X-Notes-Chunk-Pending"] = "1" + } else { + body = #"[{"id":2,"etag":"a2","readonly":true,"modified":1700000000,"title":"Two","category":"","favorite":false,"error":false,"errorType":""},{"id":1},{"id":3}]"# + } + + return (Data(body.utf8), 200, chunkHeaders) + } + } + + // MARK: - Query + + @Test("A Summary Listing Asks To Exclude The Content And Nothing Else") + func excludesContent() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + _ = try await makeServer(session: session).noteSummaries(changedSince: changedSince) + + let request = try #require(session.requests.first) + let url = try #require(request.url) + + // The title in particular is never excluded, because its presence is what tells a note sent in full from one reduced to its identifier. + #expect(try queryItems(of: request) == [pruneBefore, excludeContent]) + #expect(url.path == "/index.php/apps/notes/api/v1/notes") + #expect(request.httpMethod == "GET") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + } + + @Test("Every Summary Listing Asks To Exclude The Content") + func everyVariantExcludesContent() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let server = makeServer(session: session) + + _ = try await server.noteSummaries(changedSince: changedSince, ifChangedFrom: #"W/"c649e503de046daca1b998c2e52b2a94""#) + _ = try await server.noteSummaries(changedSince: changedSince, chunkSize: 0) + _ = try await server.noteSummaries(changedSince: changedSince, chunkSize: 25, continuingAfter: "1700000000-1699999999-7") + _ = try await server.noteSummaries(changedSince: changedSince, chunkSize: 25, ifChangedFrom: "c649e503de046daca1b998c2e52b2a94") + + let requests = session.requests + try #require(requests.count == 4) + + #expect(try queryItems(of: requests[0]) == [pruneBefore, excludeContent]) + #expect(try queryItems(of: requests[1]) == [pruneBefore, excludeContent, URLQueryItem(name: "chunkSize", value: "1")]) + #expect(try queryItems(of: requests[2]) == [pruneBefore, excludeContent, URLQueryItem(name: "chunkSize", value: "25"), URLQueryItem(name: "chunkCursor", value: "1700000000-1699999999-7")]) + #expect(try queryItems(of: requests[3]) == [pruneBefore, excludeContent, URLQueryItem(name: "chunkSize", value: "25")]) + + // The conditional variants normalize the tag as the listings of notes with their text do. + #expect(requests[0].value(forHTTPHeaderField: "If-None-Match") == #""c649e503de046daca1b998c2e52b2a94""#) + #expect(requests[1].value(forHTTPHeaderField: "If-None-Match") == nil) + #expect(requests[2].value(forHTTPHeaderField: "If-None-Match") == nil) + #expect(requests[3].value(forHTTPHeaderField: "If-None-Match") == #""c649e503de046daca1b998c2e52b2a94""#) + } + + @Test("A Listing Of Notes With Their Text Never Asks To Exclude Anything") + func notesDoNotExclude() async throws { + let fullPayload = #"[{"id":7,"etag":"9cf1","readonly":false,"modified":1700000000,"title":"Changed","category":"","content":"text","favorite":false,"error":false,"errorType":""},{"id":8}]"# + let session = MockRequesting(string: fullPayload, headerFields: completeHeaders) + let server = makeServer(session: session) + + _ = try await server.notes(changedSince: changedSince) + _ = try await server.notes(changedSince: changedSince, ifChangedFrom: "c649e503") + _ = try await server.notes(changedSince: changedSince, chunkSize: 25, continuingAfter: nil) + _ = try await server.notes(changedSince: changedSince, chunkSize: 25, ifChangedFrom: "c649e503") + + // A note decodes only with its content, so the listings returning notes must keep asking for it. + for request in session.requests { + #expect(try queryItems(of: request).contains { $0.name == "exclude" } == false) + } + + #expect(session.requests.count == 4) + } + + // MARK: - Decoding + + @Test("Decodes Summaries Alongside Identifiers And Reads The Headers") + func decodesMixedEntries() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let changes = try await makeServer(session: session).noteSummaries(changedSince: changedSince) + + let expected = NoteSummary(id: 7, entityTag: "9cf1", title: "Changed", category: "Work", isFavorite: true, modification: Date(timeIntervalSince1970: 1_700_000_000), path: "/Notes/Work/Changed.md", isShared: true, shareTypes: [.link]) + + #expect(changes.changed == [expected]) + #expect(changes.unchanged == [8]) + #expect(changes.lastModified == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(changes.entityTag == "c649e503de046daca1b998c2e52b2a94") + #expect(changes.chunkCursor == nil) + #expect(changes.pendingCount == nil) + #expect(changes.isComplete) + } + + @Test("A Response Which Carries The Content After All Still Decodes") + func ignoresContent() async throws { + let withContent = #"[{"id":7,"etag":"9cf1","readonly":true,"modified":1700000000,"title":"Changed","category":"","content":"Error: OCP\\Files\\NotPermittedException","favorite":false,"error":true,"errorType":"OCP\\Files\\NotPermittedException"}]"# + let changes = try await makeServer(session: MockRequesting(string: withContent, headerFields: completeHeaders)).noteSummaries(changedSince: changedSince) + + // A summary has no place for the content or the error, so a server or proxy which sends them anyway does not fail the call. + #expect(changes.changed.map(\.id) == [7]) + #expect(changes.changed.first?.isReadOnly == true) + #expect(changes.unchanged.isEmpty) + } + + @Test("A Summary Without An Entity Tag Fails To Decode") + func malformedSummary() async throws { + let malformed = #"[{"id":7,"readonly":false,"modified":1700000000,"title":"Changed","category":"","favorite":false}]"# + let server = makeServer(session: MockRequesting(string: malformed, headerFields: completeHeaders)) + + // The presence of the title marks a note sent in full, so one which lacks a required field is reported rather than taken for an unchanged one. + await #expect { + _ = try await server.noteSummaries(changedSince: changedSince) + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("Reads The Chunk Cursor And The Pending Count") + func readsChunkHeaders() async throws { + let session = MockRequesting(responder: makeTwoChunkResponder()) + let changes = try await makeServer(session: session).noteSummaries(changedSince: changedSince, chunkSize: 1, continuingAfter: nil) + + #expect(changes.changed.map(\.title) == ["One"]) + #expect(changes.unchanged.isEmpty) + #expect(changes.chunkCursor == "1700000000-1699999001-1") + #expect(changes.pendingCount == 1) + #expect(changes.isComplete == false) + } + + // MARK: - Conditional Requests + + @Test("Not Modified Is Reported As Nil") + func notModified() async throws { + let session = MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + let server = makeServer(session: session) + + // Nil rather than empty changes, which would claim that every note was deleted. + let single = try await server.noteSummaries(changedSince: changedSince, ifChangedFrom: "c649e503de046daca1b998c2e52b2a94") + let chunk = try await server.noteSummaries(changedSince: changedSince, chunkSize: 10, ifChangedFrom: "c649e503de046daca1b998c2e52b2a94") + + #expect(single == nil) + #expect(chunk == nil) + #expect(session.requests.count == 2) + } + + @Test("Not Modified Is Unexpected For An Unconditional Request") + func notModifiedUnconditionally() async throws { + let server = makeServer(session: MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"])) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 304)) { + _ = try await server.noteSummaries(changedSince: changedSince) + } + + await #expect(throws: RainmakerError.unexpectedStatus(code: 304)) { + _ = try await server.noteSummaries(changedSince: changedSince, chunkSize: 10, continuingAfter: "1700000000-1699999999-7") + } + } + + @Test("A Changed Response Is Returned In Full") + func changedResponse() async throws { + let session = MockRequesting(string: payload, headerFields: completeHeaders) + let changes = try #require(try await makeServer(session: session).noteSummaries(changedSince: changedSince, ifChangedFrom: "stale")) + + #expect(changes.changed.map(\.id) == [7]) + #expect(changes.unchanged == [8]) + #expect(changes.entityTag == "c649e503de046daca1b998c2e52b2a94") + } + + // MARK: - Statuses + + @Test("Maps Statuses Like The Listings Of Notes With Their Text") + func statusMapping() async throws { + let unavailable = makeServer(session: MockRequesting(string: "", statusCode: 404, headerFields: [:])) + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await unavailable.noteSummaries(changedSince: changedSince) + } + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await unavailable.noteSummaries(changedSince: changedSince, ifChangedFrom: "c649e503") + } + + let outdated = makeServer(session: MockRequesting(string: "[]", headerFields: ["X-Notes-API-Versions": "0.2, 1.3"])) + let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"]) + + await #expect(throws: expected) { + _ = try await outdated.noteSummaries(changedSince: changedSince, chunkSize: 10, continuingAfter: nil) + } + } + + @Test("Every Summary Listing Requires Credentials") + func requireCredentials() async throws { + let session = MockRequesting(responder: makeTwoChunkResponder()) + let server = Server(address: serverAddress, password: nil, user: nil, session: session, userAgent: "RainmakerTests") + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.noteSummaries(changedSince: changedSince) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.noteSummaries(changedSince: changedSince, ifChangedFrom: "c649e503") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.noteSummaries(changedSince: changedSince, chunkSize: 1) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.noteSummaries(changedSince: changedSince, chunkSize: 1, ifChangedFrom: "c649e503") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + for try await _ in server.noteSummaryChunks(changedSince: changedSince, chunkSize: 1) {} + } + + #expect(session.requests.isEmpty) + } + + // MARK: - Stream Of Chunks + + @Test("The Stream Follows The Cursors Until The Last Chunk") + func streamFollowsCursors() async throws { + let session = MockRequesting(responder: makeTwoChunkResponder()) + var chunks = [NoteSummaryChanges]() + + for try await chunk in makeServer(session: session).noteSummaryChunks(changedSince: changedSince, chunkSize: 1) { + chunks.append(chunk) + } + + #expect(chunks.map { $0.changed.map(\.id) } == [[1], [2]]) + #expect(chunks.map(\.isComplete) == [false, true]) + #expect(chunks.map(\.unchanged) == [[], [1, 3]]) + + // Every chunk of the pass leaves out the content, and each continues from the cursor of the chunk before. + let requests = session.requests + #expect(try requests.map { try queryItems(of: $0) } == [ + [pruneBefore, excludeContent, URLQueryItem(name: "chunkSize", value: "1")], + [pruneBefore, excludeContent, URLQueryItem(name: "chunkSize", value: "1"), URLQueryItem(name: "chunkCursor", value: "1700000000-1699999001-1")], + ]) + } + + @Test("Cancelling The Stream's Task Cancels The Request In Flight") + func streamCancelsRequestInFlight() async throws { + let session = SuspendingRequesting(answering: 1, through: MockRequesting(responder: makeTwoChunkResponder())) + let server = makeServer(session: session) + let received = LockedValue(0) + + let consumer = Task { + for try await _ in server.noteSummaryChunks(changedSince: changedSince, chunkSize: 1) { + received.withValue { $0 += 1 } + } + } + + // The first chunk is answered, while the request for the second one stays in flight until its task is cancelled. + #expect(try await eventually { session.suspendedCount == 1 }) + consumer.cancel() + + let result = await consumer.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + #expect(received.get() == 1) + #expect(session.receivedCount == 2) + } +} diff --git a/Tests/RainmakerTests/NoteTitleTests.swift b/Tests/RainmakerTests/NoteTitleTests.swift new file mode 100644 index 0000000..f0a59c2 --- /dev/null +++ b/Tests/RainmakerTests/NoteTitleTests.swift @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Rainmaker +import Testing + +/// +/// About predicting the ``Note/title`` the notes app gives a note through ``NoteTitle/derive(fromContent:)`` and ``NoteTitle/sanitize(_:)``. +/// +/// These tests need neither a server nor response fixtures because both functions are pure. +/// The expected titles were checked against the behaviour of the notes app's own title handling, and they are compared scalar by scalar, so that a decomposed letter does not pass for its composed form. +/// +@Suite("Note Title") struct NoteTitleTests { + @Test("Derives the title from content", arguments: [ + ("", ""), + (" \n\n ", ""), + ("Hello World", "Hello World"), + ("# Heading", "Heading"), + ("## Heading ##", "Heading"), + ("# C#", "C"), + ("#hashtag", "#hashtag"), + ("C# basics", "C# basics"), + ("\n\n# Shopping list\n- Milk", "Shopping list"), + ("Title\n=====\nBody", "Title"), + ("===\nBody", "Body"), + ("Title\r\n-----", "Title"), + ("- item", "item"), + ("* item\n* second", "item"), + ("+ item", "item"), + (" - indented item", "indented item"), + ("**bold** text", "bold text"), + ("_em_ and __strong__", "em and strong"), + ("snake_case_name", "snakecasename"), + ("***a**", "a"), + ("****", ""), + ("Café", "Café"), + ("Cafe\u{301}", "Cafe\u{301}"), + ("Notes 😀", "Notes 😀"), + ("Report (1)", "Report (1)"), + ("Fish & Chips", "Fish & Chips"), + ("1 + 1 = 2", "1 + 1 = 2"), + ("100% done", "100% done"), + ("a/b\\c:d*e|f\"gi?", "abcdefghi"), + ("../etc/passwd", "etcpasswd"), + ("...hidden", "hidden"), + (" \t leading", "leading"), + ("\u{A0}Work", "Work"), + ("Work\u{A0}", "Work"), + ("tab\there", "tab here"), + ("double space", "double space"), + ("no-break\u{A0}space", "no-break space"), + ("first\r\nsecond", "first"), + ("first\u{2028}second", "first"), + ("0", ""), + ("# 0", ""), + ]) + func derive(content: String, expected: String) { + #expect(Array(NoteTitle.derive(fromContent: content).unicodeScalars) == Array(expected.unicodeScalars)) + } + + @Test("Sanitizes a typed title", arguments: [ + ("", ""), + ("Hello World", "Hello World"), + ("# Shopping list", "# Shopping list"), + ("- item", "- item"), + ("_em_ and __strong__", "_em_ and __strong__"), + ("**bold** text", "bold text"), + ("===\nBody", "==="), + ("Line one\nLine two", "Line one"), + ("first\u{2028}second", "first"), + ("Café", "Café"), + ("Cafe\u{301}", "Cafe\u{301}"), + ("Notes 😀", "Notes 😀"), + ("Report (1)", "Report (1)"), + ("Fish & Chips", "Fish & Chips"), + ("1+1", "1+1"), + ("100%", "100%"), + ("a/b\\c:d*e|f\"gi?", "abcdefghi"), + ("..", ""), + ("../etc/passwd", "etcpasswd"), + ("...hidden", "hidden"), + ("Work\u{A0}", "Work "), + ("tab\there", "tab here"), + ("0", ""), + ("00", "00"), + ]) + func sanitize(title: String, expected: String) { + #expect(Array(NoteTitle.sanitize(title).unicodeScalars) == Array(expected.unicodeScalars)) + } + + @Test("Cuts the title off after 100 Unicode scalars", arguments: [ + (String(repeating: "a", count: 150), String(repeating: "a", count: 100)), + (String(repeating: "e\u{301}", count: 60), String(repeating: "e\u{301}", count: 50)), + (String(repeating: "😀", count: 101), String(repeating: "😀", count: 100)), + (String(repeating: "👨‍👩‍👧", count: 21), String(repeating: "👨‍👩‍👧", count: 20)), + ]) + func cutOff(text: String, expected: String) { + #expect(Array(NoteTitle.sanitize(text).unicodeScalars) == Array(expected.unicodeScalars)) + #expect(Array(NoteTitle.derive(fromContent: text).unicodeScalars) == Array(expected.unicodeScalars)) + } + + @Test("Removes the space a cut leaves at the end only when deriving") + func cutOffAtSpace() { + // The server sanitizes a derived title a second time when it names the file, which trims the space, while a typed title is sanitized once. + let text = String(repeating: "a", count: 99) + " b" + + #expect(NoteTitle.derive(fromContent: text) == String(repeating: "a", count: 99)) + #expect(NoteTitle.sanitize(text) == String(repeating: "a", count: 99) + " ") + } + + @Test("Takes no time to speak of for content made to be pathological") + func pathologicalContent() { + // Each of these makes a naive backtracking matcher take quadratic time or worse, which is why the notes app's own matcher gives up on the first one, while smaller variants of them yield what the notes app derives. + #expect(NoteTitle.derive(fromContent: "# a" + String(repeating: " ", count: 100_000) + "b") == "a") + #expect(NoteTitle.derive(fromContent: String(repeating: "*", count: 100_000)) == "") + #expect(NoteTitle.derive(fromContent: String(repeating: "\n", count: 100_000) + "- x") == "x") + #expect(NoteTitle.derive(fromContent: (1 ... 400).reversed().map { String(repeating: "*", count: $0) + "a" }.joined()) == String(repeating: "a", count: 100)) + } +} diff --git a/Tests/RainmakerTests/NoteWritingRequestTests.swift b/Tests/RainmakerTests/NoteWritingRequestTests.swift new file mode 100644 index 0000000..3b6fc2a --- /dev/null +++ b/Tests/RainmakerTests/NoteWritingRequestTests.swift @@ -0,0 +1,483 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About how ``Server/createNote(title:category:content:modification:isFavorite:)``, ``Server/updateNote(_:title:category:content:modification:isFavorite:ifMatching:)``, ``Server/deleteNote(_:)`` and ``Server/updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:)`` build their requests, and how the statuses the notes app answers them with map onto ``RainmakerError``. +/// +/// These tests deliberately do not use the fixture tree: ``URLTestSession`` neither looks at request bodies nor at request headers, so it cannot prove which values were sent and whether `If-Match` was, and a live baseline cannot be made to produce every status the notes app may answer a change with. The recorded counterparts are in ``NoteMutationTests``. A capturing ``MockRequesting`` is used instead. +/// +@Suite("Note Writing Requests") struct NoteWritingRequestTests { + let serverAddress = URL(string: "http://localhost/")! + + /// + /// A response body with a single note as the notes app sends it after creating or changing one. + /// + let payload = #"{"id":7,"etag":"9cf1","readonly":false,"modified":1700000000,"title":"Rainmaker","category":"Work","content":"text","favorite":true,"error":false,"errorType":"","internalPath":"/Notes/Work/Rainmaker.md","shareTypes":[],"isShared":false}"# + + /// + /// A response body with the settings as notes app 6.1.0 sends them after changing them. + /// + let settingsPayload = #"{"notesPath":"Notes","fileSuffix":".txt","noteMode":"preview","showHidden":true,"loadRecentOnStartUp":false}"# + + /// + /// The headers every supported response of the notes app carries. + /// + let supportedHeaders = ["X-Notes-API-Versions": "0.2, 1.3, 1.4"] + + /// + /// Build a server whose session is the given mock. + /// + private func makeServer(session: any Requesting, user: String? = "admin", password: String? = "admin") -> Server { + Server(address: serverAddress, password: password, user: user, session: session, userAgent: "RainmakerTests") + } + + /// + /// Read the JSON object a captured request carries as its body. + /// + private func jsonBody(of request: URLRequest) throws -> [String: Any] { + let body = try #require(request.httpBody) + return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + } + + /// + /// Read the path of a captured request. + /// + private func path(of request: URLRequest) throws -> String { + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + return components.path + } + + // MARK: - Creating + + @Test("Creating Posts Every Value As JSON") + func createPostsJSON() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + + // The fraction of a second is dropped rather than rounded, because the server stores whole seconds. + let note = try await makeServer(session: session).createNote(title: "Rainmaker", category: "Work", content: "text", modification: Date(timeIntervalSince1970: 1_700_000_000.9), isFavorite: true) + + let request = try #require(session.requests.first) + let body = try jsonBody(of: request) + + #expect(session.requests.count == 1) + #expect(request.httpMethod == "POST") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1/notes") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Basic ") == true) + #expect(request.value(forHTTPHeaderField: "If-Match") == nil) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + // The keys are the server's names rather than those of the model, and the moment is sent as whole seconds since the Unix epoch. + #expect(Set(body.keys) == ["title", "category", "content", "modified", "favorite"]) + #expect(body["title"] as? String == "Rainmaker") + #expect(body["category"] as? String == "Work") + #expect(body["content"] as? String == "text") + #expect(body["modified"] as? Int == 1_700_000_000) + #expect(body["favorite"] as? Bool == true) + + // The result is the note as the server stored it, which is what a caller adopts the sanitized title from. + #expect(note.id == 7) + #expect(note.entityTag == "9cf1") + #expect(note.title == "Rainmaker") + #expect(note.path == "/Notes/Work/Rainmaker.md") + } + + @Test("Creating With Defaults Leaves The Modification Out") + func createWithDefaults() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).createNote(title: "Rainmaker") + + let body = try jsonBody(of: #require(session.requests.first)) + + // Without a moment the server stamps the note with the moment it wrote it, while the other values are always sent. + #expect(Set(body.keys) == ["title", "category", "content", "favorite"]) + #expect(body["category"] as? String == "") + #expect(body["content"] as? String == "") + #expect(body["favorite"] as? Bool == false) + } + + @Test("Creating At The Epoch Leaves The Modification Out") + func createAtEpoch() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).createNote(title: "Rainmaker", modification: Date(timeIntervalSince1970: 0)) + + let body = try jsonBody(of: #require(session.requests.first)) + + // The server cannot store a moment at or before the epoch, and it takes zero as no moment at all, so nothing is sent rather than a value it would ignore. + #expect(body["modified"] == nil) + } + + @Test("Creating Without Room Is Reported As Such") + func createWithoutRoom() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 507, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.insufficientStorage) { + _ = try await makeServer(session: session).createNote(title: "Rainmaker") + } + } + + @Test("Creating Requires A Note In Response") + func createWithoutNote() async throws { + let session = MockRequesting(string: "[]", headerFields: supportedHeaders) + + await #expect { + _ = try await makeServer(session: session).createNote(title: "Rainmaker") + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("Creating Requires A Supported App") + func createOnOutdatedApp() async throws { + let session = MockRequesting(string: payload, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + + await #expect(throws: RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"])) { + _ = try await makeServer(session: session).createNote(title: "Rainmaker") + } + + // An outdated app only reveals its version in the answer to the request it already carried out, so the note was created all the same, which the documentation warns about. + #expect(session.requests.count == 1) + #expect(session.requests.first?.httpMethod == "POST") + } + + @Test("Updating On An Outdated App Has Already Sent The Change") + func updateOnOutdatedApp() async throws { + let session = MockRequesting(string: payload, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + + await #expect(throws: RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"])) { + _ = try await makeServer(session: session).updateNote(7, content: "text") + } + + // The version is only learned from the answer, so the change was made by the time the error is thrown. + #expect(session.requests.count == 1) + #expect(session.requests.first?.httpMethod == "PUT") + } + + @Test("Deleting On An Outdated App Has Already Sent The Deletion") + func deleteOnOutdatedApp() async throws { + let session = MockRequesting(string: "[]", headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + + await #expect(throws: RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"])) { + try await makeServer(session: session).deleteNote(7) + } + + // The version is only learned from the answer, so the note was deleted by the time the error is thrown. + #expect(session.requests.count == 1) + #expect(session.requests.first?.httpMethod == "DELETE") + } + + @Test("A Success Not Sent By The Notes App Is A Decoding Failure") + func createAnsweredByProxy() async throws { + let session = MockRequesting(string: "Log in", headerFields: [:]) + + // A login page an authenticating proxy answers with lacks the notes app's version header, which must not read as an outdated app. + await #expect { + _ = try await makeServer(session: session).createNote(title: "Rainmaker") + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("Cancelling A Creation Cancels Its Request") + func cancelCreation() async throws { + let session = SuspendingRequesting(answering: 0, through: MockRequesting(string: payload, headerFields: supportedHeaders)) + let server = makeServer(session: session) + + let creation = Task { + try await server.createNote(title: "Rainmaker") + } + + // The request runs within the calling task, so cancelling that task ends the request in flight, which is what a Shortcuts action relies on when it is stopped. + #expect(try await eventually { session.suspendedCount == 1 }) + creation.cancel() + + let result = await creation.result + + #expect(throws: URLError.self) { + try result.get() + } + + #expect(try await eventually { session.suspendedCount == 0 }) + #expect(session.receivedCount == 1) + } + + // MARK: - Updating + + @Test("Updating Sends Only The Given Values") + func updateSendsGivenValues() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).updateNote(7, content: "text") + + let request = try #require(session.requests.first) + let body = try jsonBody(of: request) + + #expect(request.httpMethod == "PUT") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1/notes/7") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + // Everything not given is left out rather than sent as null, so the server keeps it as it is, and without an entity tag the change is unconditional. + #expect(Set(body.keys) == ["content"]) + #expect(body["content"] as? String == "text") + #expect(request.value(forHTTPHeaderField: "If-Match") == nil) + } + + @Test("Updating Sends Every Given Value") + func updateSendsEveryValue() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).updateNote(7, title: "Rainmaker", category: "", content: "text", modification: Date(timeIntervalSince1970: 1_700_000_000), isFavorite: false) + + let body = try jsonBody(of: #require(session.requests.first)) + + // An empty category is a value of its own, which moves the note out of every category, and false is sent as well rather than taken for absent. + #expect(Set(body.keys) == ["title", "category", "content", "modified", "favorite"]) + #expect(body["title"] as? String == "Rainmaker") + #expect(body["category"] as? String == "") + #expect(body["modified"] as? Int == 1_700_000_000) + #expect(body["favorite"] as? Bool == false) + } + + @Test("Updating Only The Favorite Sends Nothing Else") + func updateFavoriteOnly() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + let note = try await makeServer(session: session).updateNote(7, isFavorite: true) + + let body = try jsonBody(of: #require(session.requests.first)) + + #expect(Set(body.keys) == ["favorite"]) + #expect(note.isFavorite) + } + + @Test("Updating Sends The Entity Tag Quoted In If-Match", arguments: ["9cf1", #""9cf1""#, #"W/"9cf1""#]) + func updateSendsIfMatch(_ entityTag: String) async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).updateNote(7, content: "text", ifMatching: entityTag) + + let request = try #require(session.requests.first) + + // The server compares the header against the quoted tag it computed, so every form of the tag is sent the same way. + #expect(request.value(forHTTPHeaderField: "If-Match") == #""9cf1""#) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + } + + @Test("A Conflict Carries The Current Note") + func conflictCarriesCurrentNote() async throws { + let session = MockRequesting(string: payload, statusCode: 412, headerFields: supportedHeaders) + let current = try JSONDecoder().decode(Note.self, from: Data(payload.utf8)) + + // The notes app sends the note as it is now along with the status, which is what a caller reapplies its change to. + await #expect(throws: RainmakerError.noteConflict(current: current)) { + _ = try await makeServer(session: session).updateNote(7, content: "changed", ifMatching: "stale") + } + } + + @Test("A Conflict Without A Note Is An Unexpected Status") + func conflictWithoutNote() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 412, headerFields: supportedHeaders) + + // Without the current note there is nothing to resolve the conflict with, so the bare status is reported. + await #expect(throws: RainmakerError.unexpectedStatus(code: 412)) { + _ = try await makeServer(session: session).updateNote(7, content: "changed", ifMatching: "stale") + } + } + + @Test("A Conflict Not Sent By The Notes App Is An Unexpected Status") + func conflictFromElsewhere() async throws { + let session = MockRequesting(string: payload, statusCode: 412, headerFields: [:]) + + // Only the notes app's own answer carries the meaning of a conflict, whatever the body looks like. + await #expect(throws: RainmakerError.unexpectedStatus(code: 412)) { + _ = try await makeServer(session: session).updateNote(7, content: "changed", ifMatching: "stale") + } + } + + @Test("Updating A Read-Only Note Is Refused") + func updateReadOnly() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 403, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.readOnly) { + _ = try await makeServer(session: session).updateNote(7, content: "changed") + } + } + + @Test("Updating A Locked Note Is Reported As Such") + func updateLocked() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 423, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.locked) { + _ = try await makeServer(session: session).updateNote(7, content: "changed") + } + } + + @Test("Updating A Missing Note Is Not Found") + func updateMissing() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 404, headerFields: supportedHeaders) + + // The server looks the note up before it compares the tag, so a deleted note is never reported as a conflict. + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).updateNote(7, content: "changed", ifMatching: "9cf1") + } + } + + // MARK: - Deleting + + @Test("Deleting Targets The Note") + func deleteTargetsNote() async throws { + let session = MockRequesting(string: "[]", headerFields: supportedHeaders) + try await makeServer(session: session).deleteNote(7) + + let request = try #require(session.requests.first) + + // A deletion carries neither a body nor a condition, because the server checks none. + #expect(session.requests.count == 1) + #expect(request.httpMethod == "DELETE") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1/notes/7") + #expect(request.httpBody == nil) + #expect(request.value(forHTTPHeaderField: "If-Match") == nil) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + } + + @Test("Deleting A Missing Note Is Not Found") + func deleteMissing() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 404, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.notFound) { + try await makeServer(session: session).deleteNote(7) + } + } + + @Test("Deleting Without The App Is Not Mistaken For A Missing Note") + func deleteWithoutApp() async throws { + let session = MockRequesting(string: "Not found", statusCode: 404, headerFields: [:]) + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + try await makeServer(session: session).deleteNote(7) + } + } + + @Test("Deleting A Read-Only Note Is Refused") + func deleteReadOnly() async throws { + let session = MockRequesting(string: #"{"errorType":"Exception"}"#, statusCode: 403, headerFields: supportedHeaders) + + await #expect(throws: RainmakerError.readOnly) { + try await makeServer(session: session).deleteNote(7) + } + } + + // MARK: - Settings + + @Test("Changing Settings Puts Only The Given Values") + func updateSettingsSendsGivenValues() async throws { + let session = MockRequesting(string: settingsPayload, headerFields: supportedHeaders) + let settings = try await makeServer(session: session).updateNotesSettings(fileSuffix: ".txt") + + let request = try #require(session.requests.first) + let body = try jsonBody(of: request) + + // The settings are only routed below version 1 of the API, whatever newer version the app serves elsewhere. + #expect(session.requests.count == 1) + #expect(request.httpMethod == "PUT") + #expect(try path(of: request) == "/index.php/apps/notes/api/v1/settings") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Basic ") == true) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + // Everything not given is left out rather than sent as null, because the server resets a setting sent as null to its default. + #expect(Set(body.keys) == ["fileSuffix"]) + #expect(body["fileSuffix"] as? String == ".txt") + + // The result is what the server stored, which is what a caller adopts sanitized values from. + #expect(settings == NotesSettings(notesPath: "Notes", fileSuffix: ".txt", noteMode: .preview, showsHiddenFiles: true, loadsRecentNoteOnStartUp: false)) + } + + @Test("Changing Settings Sends The Server's Names") + func updateSettingsSendsEveryValue() async throws { + let session = MockRequesting(string: settingsPayload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).updateNotesSettings(notesPath: "", fileSuffix: ".txt", noteMode: .preview, showsHiddenFiles: false, loadsRecentNoteOnStartUp: false) + + let body = try jsonBody(of: #require(session.requests.first)) + + // The keys are the server's names rather than those of the model, the mode is sent as its raw value, and an empty path and false are values of their own rather than taken for absent. + #expect(Set(body.keys) == ["notesPath", "fileSuffix", "noteMode", "showHidden", "loadRecentOnStartUp"]) + #expect(body["notesPath"] as? String == "") + #expect(body["fileSuffix"] as? String == ".txt") + #expect(body["noteMode"] as? String == "preview") + #expect(body["showHidden"] as? Bool == false) + #expect(body["loadRecentOnStartUp"] as? Bool == false) + } + + @Test("Changing No Setting Sends An Empty Object") + func updateSettingsWithoutValues() async throws { + let session = MockRequesting(string: settingsPayload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).updateNotesSettings() + + let body = try jsonBody(of: #require(session.requests.first)) + + // The server keeps every setting the body does not name, so an empty object changes nothing and still answers with the settings. + #expect(body.isEmpty) + } + + @Test("Changing Settings Without The App Is Reported As Such") + func updateSettingsWithoutApp() async throws { + let session = MockRequesting(string: "Not found", statusCode: 404, headerFields: [:]) + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await makeServer(session: session).updateNotesSettings(fileSuffix: ".md") + } + } + + @Test("Changing Settings Requires A Supported App") + func updateSettingsOnOutdatedApp() async throws { + let session = MockRequesting(string: settingsPayload, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + + await #expect(throws: RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"])) { + _ = try await makeServer(session: session).updateNotesSettings(fileSuffix: ".md") + } + + // The version is only learned from the answer, so the settings were changed by the time the error is thrown. + #expect(session.requests.count == 1) + #expect(session.requests.first?.httpMethod == "PUT") + } + + // MARK: - Credentials + + @Test("Changes Require Credentials") + func requireCredentials() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + let server = makeServer(session: session, user: nil, password: nil) + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.createNote(title: "Rainmaker") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.updateNote(7, content: "changed") + } + + await #expect(throws: RainmakerError.credentialsRequired) { + try await server.deleteNote(7) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.updateNotesSettings(fileSuffix: ".md") + } + + // Nothing is sent without credentials. + #expect(session.requests.isEmpty) + } +} diff --git a/Tests/RainmakerTests/NotesCapabilityVersionTests.swift b/Tests/RainmakerTests/NotesCapabilityVersionTests.swift new file mode 100644 index 0000000..70d7da1 --- /dev/null +++ b/Tests/RainmakerTests/NotesCapabilityVersionTests.swift @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About telling releases of the notes app apart through the ``Notes`` capability. +/// +/// The behaviours ``Notes/storesAttachmentsPerNote`` and ``Notes/supportsAttachmentDeletion`` describe are not announced through the notes API version, so they rest on comparing ``Notes/version`` against ``Notes/attachmentFoldersAppVersion``. These tests need neither a server nor response fixtures because the comparison is a pure function of the two strings. +/// +@Suite("Notes Capability Version") struct NotesCapabilityVersionTests { + /// + /// Decode the notes capability from the object a server advertises under the `notes` key. + /// + private func capability(_ json: String) throws -> Notes { + try JSONDecoder().decode(Notes.self, from: Data(json.utf8)) + } + + @Test("Compares Versions Component By Component As Numbers") + func numericComparison() { + #expect(Notes.compareAppVersions("6.1.0", "6.1.0") == .orderedSame) + #expect(Notes.compareAppVersions("6.0.2", "6.1.0") == .orderedAscending) + #expect(Notes.compareAppVersions("6.1.1", "6.1.0") == .orderedDescending) + + // A lexical comparison would get this one wrong. + #expect(Notes.compareAppVersions("6.10.0", "6.9.0") == .orderedDescending) + + // Missing components count as zero. + #expect(Notes.compareAppVersions("7", "6.1.0") == .orderedDescending) + #expect(Notes.compareAppVersions("6.1", "6.1.0") == .orderedSame) + } + + @Test("Orders A Pre-Release Before Its Release") + func preRelease() { + // The attachment deletion arrived during the pre-releases of 6.1.0, so treating any of them as 6.1.0 could promise a feature which is not there. + #expect(Notes.compareAppVersions("6.1.0-beta.3", "6.1.0") == .orderedAscending) + #expect(Notes.compareAppVersions("6.1.0", "6.1.0-beta.3") == .orderedDescending) + #expect(Notes.compareAppVersions("6.1.1-beta.1", "6.1.0") == .orderedDescending) + + // Build metadata is no pre-release and is ignored. + #expect(Notes.compareAppVersions("6.1.0+build.7", "6.1.0") == .orderedSame) + } + + @Test("Refuses To Compare What It Cannot Read") + func unreadable() { + #expect(Notes.compareAppVersions("", "6.1.0") == nil) + #expect(Notes.compareAppVersions("garbage", "6.1.0") == nil) + #expect(Notes.compareAppVersions("6.x.0", "6.1.0") == nil) + #expect(Notes.compareAppVersions("6..0", "6.1.0") == nil) + #expect(Notes.compareAppVersions("6.1.0", "latest") == nil) + } + + @Test("Checks The Advertised Version Against A Minimum") + func atLeast() throws { + let current = try capability(#"{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notes"}"#) + #expect(current.isAppVersion(atLeast: "6.1.0")) + #expect(current.isAppVersion(atLeast: "6.0.2")) + #expect(current.isAppVersion(atLeast: "6.1.1") == false) + + // Without a version, or with one which cannot be read, the answer is no rather than a guess. + let unversioned = try capability(#"{"api_version":["0.2","1.3","1.4"]}"#) + #expect(unversioned.isAppVersion(atLeast: "1.0.0") == false) + + let garbled = try capability(#"{"api_version":["0.2","1.3","1.4"],"version":"next"}"#) + #expect(garbled.isAppVersion(atLeast: "1.0.0") == false) + + // An unreadable minimum is not met either. + #expect(current.isAppVersion(atLeast: "soon") == false) + } + + @Test("Attachment Behaviours Follow The App Version") + func attachmentBehaviours() throws { + // What the recording containers of Nextcloud 33 and newer advertise. + let current = try capability(#"{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"}"#) + #expect(current.storesAttachmentsPerNote) + #expect(current.supportsAttachmentDeletion) + + // What the recording containers of Nextcloud 31 and 32 advertise. The API version is the same, which is why the app version has to decide. + let older = try capability(#"{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"}"#) + #expect(older.isSupported) + #expect(older.storesAttachmentsPerNote == false) + #expect(older.supportsAttachmentDeletion == false) + + let preRelease = try capability(#"{"api_version":["0.2","1.3","1.4"],"version":"6.1.0-beta.3"}"#) + #expect(preRelease.supportsAttachmentDeletion == false) + + let unversioned = try capability(#"{"api_version":["0.2","1.3","1.4"]}"#) + #expect(unversioned.storesAttachmentsPerNote == false) + #expect(unversioned.supportsAttachmentDeletion == false) + } +} diff --git a/Tests/RainmakerTests/NotesRequestTests.swift b/Tests/RainmakerTests/NotesRequestTests.swift index 4ae2f06..14cb9fb 100644 --- a/Tests/RainmakerTests/NotesRequestTests.swift +++ b/Tests/RainmakerTests/NotesRequestTests.swift @@ -6,7 +6,7 @@ import Foundation import Testing /// -/// About how ``Server/notes()`` and ``Server/notes(changedSince:)`` build their request, how they enforce the minimum notes API version, and how they decode the two shapes the server answers with. +/// About how ``Server/notes()`` and ``Server/notes(changedSince:)`` build their request, how they enforce the minimum notes API version, how the statuses of the notes API map onto ``RainmakerError``, and how they decode the two shapes the server answers with. /// /// These tests deliberately do not use the fixture tree: ``FixtureLocator`` keys fixtures by HTTP method and URL path only, so a replayed test still finds its fixture when the query string is wrong or missing entirely and therefore cannot prove anything about it. A capturing ``MockRequesting`` is used instead, which also makes it possible to serve responses a live baseline cannot be made to produce, such as those of a server without the notes app, one running an app too old to be supported, or one answering a success with something other than notes. /// @@ -121,21 +121,45 @@ import Testing @Test("Settings Decode And Ignore What Is Not Modelled") func settingsDecoding() async throws { - // The `noteMode` field is what a live server sends beyond what the API documents, so it has to be ignored rather than break the lookup. - let payload = #"{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"}"# + // A field a future release of the notes app may add beyond what this library models has to be ignored rather than break the lookup. + let payload = #"{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","futureSetting":42}"# let settings = try await makeServer(body: payload).notesSettings() #expect(settings.notesPath == "Notizen") #expect(settings.fileSuffix == ".md") + #expect(settings.noteMode == .rich) } // MARK: - Availability And Version - @Test("Unavailable App Is Not Found") + @Test("Unavailable App Is Reported As Such") func unavailableApp() async throws { - let server = makeServer(body: "Not found", statusCode: 404) + let server = makeServer(body: "Not found", statusCode: 404, headerFields: [:]) - // Without the notes app installed and enabled the route does not exist at all. + // Without the notes app installed and enabled the route does not exist at all, and the server answers on its own behalf without the header the app adds. Reporting that as a missing note would make a client keeping its own copy delete its notes because the app went away. + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await server.notes() + } + } + + @Test("Unavailable App Is Reported As Such By Every Notes Feature") + func unavailableAppEverywhere() async throws { + let server = makeServer(body: "", statusCode: 404, headerFields: [:]) + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await server.notes(changedSince: Date(timeIntervalSince1970: 1_600_000_000)) + } + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await server.notesSettings() + } + } + + @Test("Missing Note Is Not Found") + func missingNote() async throws { + let server = makeServer(body: #"{"message":"Note does not exist"}"#, statusCode: 404) + + // A not found status the notes app sends itself carries its header and concerns the subject of the request rather than the app. await #expect(throws: RainmakerError.notFound) { _ = try await server.notes() } @@ -143,8 +167,8 @@ import Testing @Test("App Older Than The Minimum API Version Is Rejected") func outdatedApp() async throws { - let server = makeServer(body: emptyList, headerFields: ["X-Notes-API-Versions": "0.2, 1.2"]) - let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.3", advertised: ["0.2", "1.2"]) + let server = makeServer(body: emptyList, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"]) // An app which answers but predates the required API version is reported as such rather than as a missing app, because updating it is what a client should tell the user to do. await #expect(throws: expected) { @@ -152,20 +176,41 @@ import Testing } } - @Test("Missing Version Header Is Rejected") + @Test("Missing Version Header Is A Decoding Failure") func missingVersionHeader() async throws { let server = makeServer(body: emptyList, headerFields: [:]) - let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.3", advertised: []) - // Every response of a supported app advertises its API versions, so their absence means the app cannot be relied upon. - await #expect(throws: expected) { + // Every response of the notes app advertises its API versions, so a success without them was not sent by the app at all and says nothing about its version. + await #expect { + _ = try await server.notes() + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("A Login Page Of A Proxy Is A Decoding Failure") + func proxyLoginPage() async throws { + let server = makeServer(body: "Log in", headerFields: [:]) + + // An authenticating proxy or a maintenance page answers on the notes app's behalf without its header, which must not be reported as an outdated app the user would be told to update. + await #expect { _ = try await server.notes() + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true } } @Test("The Minimum API Version Is Accepted") func minimumVersion() async throws { - let server = makeServer(body: emptyList, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + let server = makeServer(body: emptyList, headerFields: ["X-Notes-API-Versions": "0.2, 1.4"]) // The requirement is a floor, so an app serving exactly it has to work. await #expect(throws: Never.self) { @@ -176,7 +221,7 @@ import Testing @Test("A Newer Major API Version Alone Does Not Satisfy The Requirement") func newerMajorVersion() async throws { let server = makeServer(body: emptyList, headerFields: ["X-Notes-API-Versions": "2.0"]) - let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.3", advertised: ["2.0"]) + let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["2.0"]) // A future major version is a different API with its own base path, so it must not silently pass a check meant for this one. await #expect(throws: expected) { @@ -187,7 +232,7 @@ import Testing @Test("The Requirement Is Enforced On The Settings Lookup Too") func outdatedAppOnSettings() async throws { let server = makeServer(body: #"{"notesPath":"Notes","fileSuffix":".md"}"#, headerFields: ["X-Notes-API-Versions": "0.2"]) - let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.3", advertised: ["0.2"]) + let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2"]) await #expect(throws: expected) { _ = try await server.notesSettings() @@ -197,7 +242,7 @@ import Testing @Test("The Requirement Is Enforced On Incremental Retrieval Too") func outdatedAppOnIncrementalRetrieval() async throws { let server = makeServer(body: emptyList, headerFields: ["X-Notes-API-Versions": "0.2"]) - let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.3", advertised: ["0.2"]) + let expected = RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2"]) await #expect(throws: expected) { _ = try await server.notes(changedSince: Date(timeIntervalSince1970: 1_600_000_000)) @@ -211,20 +256,120 @@ import Testing } #expect(try notes(advertising: #""0.2","1.3","1.4""#).isSupported) - #expect(try notes(advertising: #""0.2","1.3""#).isSupported) #expect(try notes(advertising: #""1.4""#).isSupported) + #expect(try notes(advertising: #""0.2","1.3""#).isSupported == false) #expect(try notes(advertising: #""0.2","1.2""#).isSupported == false) #expect(try notes(advertising: #""2.0""#).isSupported == false) #expect(try notes(advertising: "").isSupported == false) // A version scheme this library does not know about must not make an otherwise supported server look unsupported. - #expect(try notes(advertising: #""nonsense","1.3""#).isSupported) + #expect(try notes(advertising: #""nonsense","1.4""#).isSupported) // A patch component the server does not send today would still have to read as supported. - #expect(try notes(advertising: #""1.3.1""#).isSupported) - #expect(try notes(advertising: #""1.2.9""#).isSupported == false) + #expect(try notes(advertising: #""1.4.1""#).isSupported) + #expect(try notes(advertising: #""1.3.9""#).isSupported == false) + + #expect(Notes.minimumAPIVersion == "1.4") + } + + // MARK: - Status Mapping + + @Test("Statuses The Notes App Sends Itself Carry Its Meaning", arguments: [ + (403, RainmakerError.readOnly), + (423, RainmakerError.locked), + (507, RainmakerError.insufficientStorage), + ]) + func statusWithHeader(statusCode: Int, expected: RainmakerError) async throws { + let server = makeServer(body: #"{"message":"Failure"}"#, statusCode: statusCode) + + // Only a response the notes app sends itself carries its header, so only then does the status say something about a note. + await #expect(throws: expected) { + _ = try await server.notes() + } + } + + @Test("Statuses Without The Notes App Header Stay Unexpected", arguments: [403, 412, 423, 507]) + func statusWithoutHeader(statusCode: Int) async throws { + let server = makeServer(body: "Failure", statusCode: statusCode, headerFields: [:]) + + // A proxy or the server itself answering on the app's behalf says nothing about notes, so the status is passed on as it is. + await #expect(throws: RainmakerError.unexpectedStatus(code: statusCode)) { + _ = try await server.notes() + } + } + + @Test("A Conflict Carries The Current Note") + func conflictWithNote() async throws { + let payload = #"{"id":7,"etag":"9cf1","readonly":false,"modified":1700000000,"title":"Current","category":"","content":"text","favorite":false,"error":false,"errorType":""}"# + let server = makeServer(body: payload, statusCode: 412) + + // The notes app sends the note as it currently is along with the conflict, which is what a client resolves it with. + await #expect { + _ = try await server.notes() + } throws: { error in + guard case let RainmakerError.noteConflict(current: note) = error else { + return false + } + + return note.id == 7 && note.entityTag == "9cf1" && note.title == "Current" + } + } + + @Test("A Conflict Without A Note Stays Unexpected") + func conflictWithoutNote() async throws { + let server = makeServer(body: #"{"message":"Failure"}"#, statusCode: 412) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 412)) { + _ = try await server.notes() + } + } + + @Test("A Method The Endpoint Does Not Route Is Not Allowed", arguments: [true, false]) + func methodNotAllowed(withHeader: Bool) async throws { + let server = makeServer(body: "", statusCode: 405, headerFields: withHeader ? supportedHeaders : [:]) + + // The server answers this before the notes app is involved, so the header is not there to gate it. + await #expect(throws: RainmakerError.methodNotAllowed) { + _ = try await server.notes() + } + } + + @Test("Not Modified Is Unexpected For An Unconditional Request") + func notModifiedUnconditionally() async throws { + let server = makeServer(body: "", statusCode: 304) + + await #expect(throws: RainmakerError.unexpectedStatus(code: 304)) { + _ = try await server.notes() + } + } + + @Test("Rejected Credentials Stay Unexpected") + func rejectedCredentials() async throws { + let server = makeServer(body: #"{"message":"Current user is not logged in"}"#, statusCode: 401, headerFields: [:]) + + // The event stream recognizes rejected credentials by exactly this error, so it must not be mapped onto anything else. + await #expect(throws: RainmakerError.unexpectedStatus(code: 401)) { + _ = try await server.notes() + } + } + + @Test("Requests Ignore The Local Cache") + func requestsIgnoreLocalCache() async throws { + let headerFields = supportedHeaders + + let session = MockRequesting { request in + let body = request.url?.path.hasSuffix("/settings") == true ? #"{"notesPath":"Notes","fileSuffix":".md"}"# : "[]" + return (Data(body.utf8), 200, headerFields) + } + + let server = makeServer(session: session) + _ = try await server.notes() + _ = try await server.notes(changedSince: Date(timeIntervalSince1970: 1_700_000_000)) + _ = try await server.notesSettings() - #expect(Notes.minimumAPIVersion == "1.3") + // A response answered from a URLCache would hide what the server answers now, and a session shared between accounts could even be answered with another account's notes. + #expect(session.requests.count == 3) + #expect(session.requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData }) } // MARK: - Decoding @@ -233,7 +378,7 @@ import Testing func malformedResponse() async throws { let server = makeServer(body: "Log in") - // This endpoint carries no OCS envelope whose status could vouch for the payload, so a success response with something else entirely, such as a login page served by a proxy, has to surface as a library error rather than as an opaque Foundation one. + // This endpoint carries no OCS envelope whose status could vouch for the payload, so a success response which carries the notes app's header but a body other than notes has to surface as a library error rather than as an opaque Foundation one. await #expect { _ = try await server.notes() } throws: { error in diff --git a/Tests/RainmakerTests/NotesTests.swift b/Tests/RainmakerTests/NotesTests.swift index 8bb243d..bbe3cfd 100644 --- a/Tests/RainmakerTests/NotesTests.swift +++ b/Tests/RainmakerTests/NotesTests.swift @@ -7,11 +7,11 @@ import RainmakerTestServerTags import Testing /// -/// About listing the notes of the authenticated user. +/// About listing the notes of the authenticated user and retrieving a single one of them. /// /// The fixtures backing this suite are recorded against a container which has the notes app installed on demand, because that app is not part of a Nextcloud installation. See ``FixtureOrchestrator/enabledApps``. The notes themselves are seeded as files in the account's notes folder by ``FixtureProvisioner``, which also stamps them with fixed modification dates so that what the server reports as `modified` is reproducible. /// -/// How a request is built, how an unavailable app surfaces and how the two response shapes decode is covered by ``NotesRequestTests`` instead, which drives a mock rather than the fixture tree. +/// How a request is built, how an unavailable app surfaces and how the two response shapes decode is covered by ``NotesRequestTests`` instead, which drives a mock rather than the fixture tree, and ``SingleNoteRequestTests`` does the same for the retrieval of a single note. /// @Suite("Notes") struct NotesTests: ServerTesting { /// @@ -63,12 +63,21 @@ import Testing #expect(uncategorized.entityTag.isEmpty == false) #expect(uncategorized.description == "Rainmaker") + // The path is pinned only below the notes folder, whose name is derived from the container's locale. The seeded notes are not shared with anyone. + #expect(uncategorized.path?.hasPrefix("/") == true) + #expect(uncategorized.path?.hasSuffix("/Rainmaker.md") == true) + #expect(uncategorized.isShared == false) + #expect(uncategorized.shareTypes.isEmpty) + // A note in a sub-folder of the notes folder is reported under that folder as its category. let categorized = try #require(notes.first { $0.title == "Pancakes" }) #expect(categorized.category == "Recipes") #expect(categorized.content == "# Pancakes\n") #expect(categorized.modification == Date(timeIntervalSince1970: 1_600_000_000)) + // The category is the folder the note's file sits in, which the path reflects. + #expect(categorized.path?.hasSuffix("/Recipes/Pancakes.md") == true) + // The identifiers are assigned by the server and are only meaningful in being present and telling the notes apart. #expect(notes.allSatisfy { $0.id > 0 }) #expect(Set(notes.map(\.id)).count == notes.count) @@ -86,11 +95,25 @@ import Testing @Test("Settings", arguments: ServerVersion.allCases) func settings(_ serverVersion: ServerVersion) async throws { let server = try makeServer(serverVersion: serverVersion) + + // The capabilities say which release of the notes app answers, which decides whether the preferences of newer releases are sent. Branching on them rather than on the server version keeps the test independent of which release the app store hands each server version. + let notes = try #require(try await server.capabilities().get(Notes.self)) let settings = try await server.notesSettings() // Neither value is pinned to a literal: the folder is derived from the account's locale, so the container's language decides it, and the suffix is a user setting. What matters is that the server reports something usable, since the recording provisions its notes into exactly this folder. #expect(settings.notesPath.isEmpty == false) #expect(settings.fileSuffix.hasPrefix(".")) + + // Every supported release sends a mode, and it is one of the known ones rather than something which decoded as absent. + #expect(settings.noteMode != nil) + + if notes.isAppVersion(atLeast: "6.1.0") { + #expect(settings.showsHiddenFiles != nil) + #expect(settings.loadsRecentNoteOnStartUp != nil) + } else { + #expect(settings.showsHiddenFiles == nil) + #expect(settings.loadsRecentNoteOnStartUp == nil) + } } @Test("Fetch Everything Changed", arguments: ServerVersion.allCases) @@ -102,6 +125,14 @@ import Testing #expect(changes.changed.count == 2) #expect(changes.unchanged.isEmpty) #expect(changes.changed.map(\.title).sorted() == ["Pancakes", "Rainmaker"]) + + // Only the presence of the moment and the entity tag is asserted, for the same reason as with the entity tag of a note: both are canonicalized when recorded, while a recording run is handed the live values. + #expect(changes.lastModified != nil) + #expect(changes.entityTag?.isEmpty == false) + + // No chunk size was asked for, so the server answers in one response which lists every note. + #expect(changes.isComplete) + #expect(changes.pendingCount == nil) } @Test("Fetch Nothing Changed", arguments: ServerVersion.allCases) @@ -114,5 +145,112 @@ import Testing #expect(changes.unchanged.count == 2) #expect(changes.unchanged.allSatisfy { $0 > 0 }) #expect(Set(changes.unchanged).count == changes.unchanged.count) + + // A response without a single note in full still says what to continue from and how to ask whether it changed. + #expect(changes.lastModified != nil) + #expect(changes.entityTag?.isEmpty == false) + #expect(changes.isComplete) + } + + @Test("Fetch First Chunk", arguments: ServerVersion.allCases) + func fetchFirstChunk(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let changes = try await server.notes(changedSince: beforeRecording, chunkSize: 1, continuingAfter: nil) + + // Both seeded notes changed since the requested moment, so a chunk of one sends one of them in full and leaves the other pending. Which one comes first depends on when the server noticed each, so only the count is asserted. + #expect(changes.changed.count == 1) + #expect(changes.pendingCount == 1) + + // A chunk which is not the last one lists no identifiers, which is why deletions must not be derived from it. + #expect(changes.unchanged.isEmpty) + #expect(changes.isComplete == false) + + // Only the presence of the cursor and the moment is asserted, because both are canonicalized when recorded, while a recording run is handed the live values. + #expect(changes.chunkCursor?.isEmpty == false) + #expect(changes.lastModified != nil) + } + + @Test("Fetch Summaries", arguments: ServerVersion.allCases) + func fetchSummaries(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let changes = try await server.noteSummaries(changedSince: beforeRecording) + + // The server prunes nothing, as for ``fetchEverythingChanged(_:)``, and describes every note in full but for its text, which the recorded body does not carry at all. + #expect(changes.changed.count == 2) + #expect(changes.unchanged.isEmpty) + #expect(changes.changed.map(\.title).sorted() == ["Pancakes", "Rainmaker"]) + + let uncategorized = try #require(changes.changed.first { $0.title == "Rainmaker" }) + #expect(uncategorized.category == "") + #expect(uncategorized.isFavorite == false) + #expect(uncategorized.isReadOnly == false) + #expect(uncategorized.modification == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(uncategorized.entityTag.isEmpty == false) + #expect(uncategorized.path?.hasSuffix("/Rainmaker.md") == true) + #expect(uncategorized.isShared == false) + #expect(uncategorized.shareTypes.isEmpty) + + let categorized = try #require(changes.changed.first { $0.title == "Pancakes" }) + #expect(categorized.category == "Recipes") + #expect(categorized.modification == Date(timeIntervalSince1970: 1_600_000_000)) + #expect(categorized.path?.hasSuffix("/Recipes/Pancakes.md") == true) + + // The headers are read as for a listing with text, so only their presence is asserted for the reasons ``fetchEverythingChanged(_:)`` gives. + #expect(changes.lastModified != nil) + #expect(changes.entityTag?.isEmpty == false) + #expect(changes.isComplete) + #expect(changes.pendingCount == nil) + } + + @Test("Fetch Summaries First Chunk", arguments: ServerVersion.allCases) + func fetchSummariesFirstChunk(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let changes = try await server.noteSummaries(changedSince: beforeRecording, chunkSize: 1, continuingAfter: nil) + + // Chunking works on summaries as on notes with their text, see ``fetchFirstChunk(_:)``. + #expect(changes.changed.count == 1) + #expect(changes.pendingCount == 1) + #expect(changes.unchanged.isEmpty) + #expect(changes.isComplete == false) + #expect(changes.chunkCursor?.isEmpty == false) + #expect(changes.lastModified != nil) + } + + @Test("Fetch Note", arguments: ServerVersion.allCases) + func fetchNote(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + + // The identifier is assigned by the server, so it is taken from the listing rather than pinned. The recorded lookup is filed under that identifier, which is why both requests are recorded together. + let listed = try #require(try await server.notes().first { $0.title == "Rainmaker" }) + let note = try await server.note(listed.id) + + // The lookup reports the very note the listing does, field by field, which is what lets a client refresh one note without listing all of them. + #expect(note == listed) + #expect(note.content == "# Rainmaker\n") + #expect(note.category == "") + #expect(note.modification == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(note.path?.hasSuffix("/Rainmaker.md") == true) + #expect(note.hasError == false) + #expect(note.entityTag.isEmpty == false) + } + + @Test("Fetch Unchanged Note", arguments: ServerVersion.allCases) + func fetchUnchangedNote(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let listed = try #require(try await server.notes().first { $0.title == "Rainmaker" }) + + // The entity tag the listing reports for a note is the one the server compares a conditional lookup against, so nothing changed in between and the server answers with not modified. When recorded, the live tag is sent and the server's real answer is captured, while a replay finds that answer by method and path alone. + let note = try await server.note(listed.id, ifChangedFrom: listed.entityTag) + #expect(note == nil) + } + + @Test("Fetch Missing Note", arguments: ServerVersion.allCases) + func fetchMissingNote(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + + // No deployment assigns an identifier this large to a baseline with two notes, so the notes app itself answers that the note does not exist, which is told apart from an absent app by the header it adds. + await #expect(throws: RainmakerError.notFound) { + _ = try await server.note(999_999_999) + } } } diff --git a/Tests/RainmakerTests/PollTests.swift b/Tests/RainmakerTests/PollTests.swift index b583c9e..0bdebf4 100644 --- a/Tests/RainmakerTests/PollTests.swift +++ b/Tests/RainmakerTests/PollTests.swift @@ -9,13 +9,33 @@ import Testing /// /// Login flow related tests. /// +/// The fixtures are hand-authored, because a successful poll needs a login flow a user completed in a browser. +/// ``Server/poll(_:)`` is covered by ``pollingPending(_:)`` and ``pollingResult(_:)``, while ``pollingFailure(_:)`` and ``pollingSuccess(_:)`` keep covering the unchanged behaviour of the deprecated ``Server/poll(_:token:)``, which they call through ``Serving`` so that its deprecation does not warn. +/// The statuses are covered with ``MockRequesting`` in ``LoginRequestTests``. +/// @Suite("Polling") struct PollTests: ServerTesting { + /// + /// The token of the login flow the tests poll, which the replayed fixtures do not depend on. + /// let token = "some-token" + /// + /// The endpoint the tests poll, which is where the server's login flows point to. + /// + var endpoint: URL { + serverAddress.appendingCompatibility(component: "index.php").appendingCompatibility(component: "login").appendingCompatibility(component: "v2").appendingCompatibility(component: "poll") + } + + /// + /// The login flow the tests poll through ``Server/poll(_:)``, created through its public initializer as a client keeping a flow across launches would. + /// + var flow: LoginFlow { + LoginFlow(endpoint: endpoint, entry: serverAddress.appendingCompatibility(path: "index.php/login/v2/flow/some-login-token", directoryHint: .notDirectory), token: token) + } + @Test("Polling Failure", arguments: ServerVersion.allCases) func pollingFailure(_ serverVersion: ServerVersion) async throws { let server = try makeServer(serverVersion: serverVersion) - let endpoint = serverAddress.appendingCompatibility(component: "index.php").appendingCompatibility(component: "login").appendingCompatibility(component: "v2").appendingCompatibility(component: "poll") await #expect(throws: RainmakerError.responseDecodingFailed(reason: "The server returned no login flow result on polling.")) { _ = try await server.poll(endpoint, token: token) @@ -25,10 +45,27 @@ import Testing @Test("Polling Success", arguments: ServerVersion.allCases) func pollingSuccess(_ serverVersion: ServerVersion) async throws { let server = try makeServer(serverVersion: serverVersion) - let endpoint = serverAddress.appendingCompatibility(component: "index.php").appendingCompatibility(component: "login").appendingCompatibility(component: "v2").appendingCompatibility(component: "poll") await #expect(throws: Never.self) { _ = try await server.poll(endpoint, token: token) } } + + @Test("Polling A Pending Flow Returns Nil", arguments: ServerVersion.allCases) + func pollingPending(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let result = try await server.poll(flow) + + #expect(result == nil) + } + + @Test("Polling A Completed Flow Returns The Result", arguments: ServerVersion.allCases) + func pollingResult(_ serverVersion: ServerVersion) async throws { + let server = try makeServer(serverVersion: serverVersion) + let result = try #require(try await server.poll(flow)) + + #expect(result.name == "admin") + #expect(result.password.isEmpty == false) + #expect(result.server == URL(string: "http://localhost")) + } } diff --git a/Tests/RainmakerTests/RequestFactoryTests.swift b/Tests/RainmakerTests/RequestFactoryTests.swift index 3fada26..9902a7a 100644 --- a/Tests/RainmakerTests/RequestFactoryTests.swift +++ b/Tests/RainmakerTests/RequestFactoryTests.swift @@ -63,6 +63,24 @@ import Testing #expect(components.queryItems == [URLQueryItem(name: "pruneBefore", value: "1700000000")]) } + @Test("App Request Encodes Reserved Characters In Query Values") + func appRequestEncodesReservedCharacters() throws { + let server = makeServer() + let value = "a+b c&d=e#f/Ä?.png" + let request = try server.makeAppRequest(for: "notes/api/v1.4/attachment/7", method: .get, queryItems: [URLQueryItem(name: "path", value: value)]) + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + // `queryItems` alone leaves `+` as it is, which the server decodes as a space, so `a+b` would arrive as `a b`. + var reference = URLComponents() + reference.queryItems = [URLQueryItem(name: "path", value: value)] + #expect(reference.percentEncodedQuery?.contains("a+b") == true) + + // The factory encodes it, as well as the delimiters `&`, `=` and `#`, while leaving `/` and `?` readable. + #expect(components.percentEncodedQuery == "path=a%2Bb%20c%26d%3De%23f/%C3%84?.png") + #expect(components.queryItems == [URLQueryItem(name: "path", value: value)]) + } + @Test("App Request Without Query Items Is Unchanged") func appRequestWithoutQueryItems() throws { let server = makeServer() @@ -127,6 +145,18 @@ import Testing #expect(headers?["Authorization"] == expectedBasicAuthorization) } + @Test("OCS Request Encodes Reserved Characters In Query Values") + func ocsRequestEncodesReservedCharacters() throws { + let server = makeServer() + let request = try server.makeOCSRequest(for: "apps/activity/api/v2/activity/all", method: .get, queryItems: [URLQueryItem(name: "object_type", value: "a+b&c=d"), URLQueryItem(name: "limit", value: "50")]) + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + // Plain values read as before, while `+`, `&` and `=` within a value cannot be mistaken for a space or for delimiters. + #expect(components.percentEncodedQuery == "object_type=a%2Bb%26c%3Dd&limit=50") + #expect(components.queryItems == [URLQueryItem(name: "object_type", value: "a+b&c=d"), URLQueryItem(name: "limit", value: "50")]) + } + @Test("OCS Request Without Query Items Is Unchanged") func ocsRequestWithoutQueryItems() throws { let server = makeServer() diff --git a/Tests/RainmakerTests/Responses/31.0.14/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json b/Tests/RainmakerTests/Responses/31.0.14/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json new file mode 100644 index 0000000..3d0d93a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"enabled":true,"storageLocation":"\/var\/www\/html\/data\/admin","id":"admin","firstLoginTimestamp": 946684800,"lastLoginTimestamp": 946684800,"lastLogin": 946684800000,"backend":"Database","subadmin":[],"quota":{"free": 0,"used": 0,"total": 0,"relative": 0,"quota":-3},"manager":"","avatarScope":"v2-federated","email":null,"emailScope":"v2-federated","additional_mail":[],"additional_mailScope":[],"displayname":"admin","display-name":"admin","displaynameScope":"v2-federated","phone":"","phoneScope":"v2-local","address":"","addressScope":"v2-local","website":"","websiteScope":"v2-local","twitter":"","twitterScope":"v2-local","fediverse":"","fediverseScope":"v2-local","organisation":"","organisationScope":"v2-local","role":"","roleScope":"v2-local","headline":"","headlineScope":"v2-local","biography":"","biographyScope":"v2-local","profile_enabled":"1","profile_enabledScope":"v2-local","pronouns":"","pronounsScope":"v2-federated","groups":["admin"],"language":"de_DE","locale":"","notify_email":null,"backendCapabilities":{"setDisplayName":true,"setPassword":true}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json b/Tests/RainmakerTests/Responses/31.0.14/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json index 258c018..6070b3f 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json +++ b/Tests/RainmakerTests/Responses/31.0.14/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json @@ -1 +1 @@ -{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/yfoNpB5dOfHotXtWWFBe4tR0Xwjmc1eAL65pqveNkxfGbAVQo3sp2E0HOy5IX4gGGooIgn4Nu6CNEMJKMIxZpz1b2pW7d42YZDpGmR1g4Pg5jWI8ol9IzaY0PsIgWXa3"} \ No newline at end of file +{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/REDACTED"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt index 482f8bd..ff634c5 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt +++ b/Tests/RainmakerTests/Responses/31.0.14/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt @@ -1,2 +1,2 @@ -HTTP/1.1 412 +HTTP/1.1 412 Precondition Failed Content-Type: application/xml; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/134/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/134/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/134/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/134/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/134/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/134/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/09448e9622f372c16dfc6c51bb7f2d7a.png/Body.xml b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/09448e9622f372c16dfc6c51bb7f2d7a.png/Body.xml new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/09448e9622f372c16dfc6c51bb7f2d7a.png/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/09448e9622f372c16dfc6c51bb7f2d7a.png/Headers.txt new file mode 100644 index 0000000..58e46ab --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/09448e9622f372c16dfc6c51bb7f2d7a.png/Headers.txt @@ -0,0 +1 @@ +HTTP/1.1 204 No Content diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/134/Body.bin b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/134/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/134/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/134/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/134/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/134/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..a751c02 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":31,"minor":0,"micro":14,"string":"31.0.14","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"5.0.2","text_processing":{"task_types":["free_prompt","headline","summary","topics"]}},"circles":{"version":"31.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}]},"dav":{"chunking":"1.0","bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.21.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","reactions"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key"],"experiments":["enabled"]},"version":"21.1.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jenna-kim-the-globe.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/134/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/134/Body.json new file mode 100644 index 0000000..2842de7 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/134/Body.json @@ -0,0 +1 @@ +{"filename":"09448e9622f372c16dfc6c51bb7f2d7a.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/134/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/134/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/134/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..4461990 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":134,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/137/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/137/Body.json new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt new file mode 100644 index 0000000..b8b7b7a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 405 Method Not Allowed +Content-Type: text/html; charset=UTF-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/137/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/137/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/137/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/137/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/137/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/137/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/a618587322e5a04e19848d834125b6d0.png/Body.xml b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/a618587322e5a04e19848d834125b6d0.png/Body.xml new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/a618587322e5a04e19848d834125b6d0.png/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/a618587322e5a04e19848d834125b6d0.png/Headers.txt new file mode 100644 index 0000000..58e46ab --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/a618587322e5a04e19848d834125b6d0.png/Headers.txt @@ -0,0 +1 @@ +HTTP/1.1 204 No Content diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/137/Body.bin b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/137/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/137/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..a751c02 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":31,"minor":0,"micro":14,"string":"31.0.14","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"5.0.2","text_processing":{"task_types":["free_prompt","headline","summary","topics"]}},"circles":{"version":"31.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}]},"dav":{"chunking":"1.0","bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.21.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","reactions"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key"],"experiments":["enabled"]},"version":"21.1.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jenna-kim-the-globe.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/137/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/137/Body.json new file mode 100644 index 0000000..8c70ef7 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/137/Body.json @@ -0,0 +1 @@ +{"filename":"a618587322e5a04e19848d834125b6d0.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/137/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..5909fbb --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":137,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/139/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/139/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/139/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/139/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/139/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/139/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/139/Body.bin b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/139/Body.bin new file mode 100644 index 0000000..21a593d --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/139/Body.bin @@ -0,0 +1 @@ +{"errorType":"OCP\\Files\\NotFoundException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/139/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/139/Headers.txt new file mode 100644 index 0000000..ee5eadf --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/139/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..f9dbe5e --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":139,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/133/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/133/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/133/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/133/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/133/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/133/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..578d61a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":133,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/133/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/133/Body.json new file mode 100644 index 0000000..578d61a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/133/Body.json @@ -0,0 +1 @@ +{"id":133,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/133/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/133/Headers.txt new file mode 100644 index 0000000..c3d700c --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/133/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 412 Precondition Failed +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..0428ebb --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/134/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/134/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/134/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/134/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/134/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/134/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..6cd7634 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":134,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/134/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/134/Body.json new file mode 100644 index 0000000..91fabad --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/134/Body.json @@ -0,0 +1 @@ +{"id":134,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":true,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/134/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/134/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/134/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/136/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/136/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/136/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/136/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/136/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/136/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/136/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/136/Body.json new file mode 100644 index 0000000..0428ebb --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/136/Body.json @@ -0,0 +1 @@ +{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/136/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/136/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/136/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..d29550b --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":136,"title":"Rainmaker Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/136/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/136/Body.json new file mode 100644 index 0000000..1988de1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/136/Body.json @@ -0,0 +1 @@ +{"id":136,"title":"Rainmaker Test","modified":1650000100,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Changed\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/136/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/136/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/136/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/140/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/140/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/140/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/140/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/140/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/140/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..416a917 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":140,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/140/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/140/Body.json new file mode 100644 index 0000000..79e8cc3 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/140/Body.json @@ -0,0 +1 @@ +{"id":140,"title":"Rainmaker Renamed","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Renamed.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/140/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/140/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/140/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/142/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/142/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/142/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/142/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/142/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/142/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..4ca2551 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":142,"title":"ABC","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/ABC.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..bffa72a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..bffa72a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json index 364721a..feb2c84 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":88,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":92,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":85,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":97,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 364721a..feb2c84 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":88,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":92,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":85,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":97,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..32509e1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":90,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..0428ebb --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/83/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/83/Body.json new file mode 100644 index 0000000..54ab58d --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/83/Body.json @@ -0,0 +1 @@ +{"id":83,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/83/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/83/Headers.txt new file mode 100644 index 0000000..a03eb36 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/83/Headers.txt @@ -0,0 +1,4 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..fe35f19 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":83,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":85,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 5228f86..cf4278f 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":146},{"id":145}] \ No newline at end of file +[{"id":135},{"id":136}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..36a022b --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":83,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"},{"id":95,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..d1ea600 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":83,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/94/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/94/Body.json new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/94/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/94/Headers.txt new file mode 100644 index 0000000..b535abc --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/94/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 304 Not Modified +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..c44901b --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":94,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":100,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..a751c02 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":31,"minor":0,"micro":14,"string":"31.0.14","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"5.0.2","text_processing":{"task_types":["free_prompt","headline","summary","topics"]}},"circles":{"version":"31.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}]},"dav":{"chunking":"1.0","bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.21.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","reactions"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key"],"experiments":["enabled"]},"version":"21.1.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jenna-kim-the-globe.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1 @@ +[] diff --git a/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..343df23 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..5f9f132 --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1,5 @@ +{ + "appPassword" : "REDACTED", + "loginName" : "admin", + "server" : "http://localhost" +} diff --git a/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..fb7791b --- /dev/null +++ b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json index 75a9f3b..5f9f132 100644 --- a/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json +++ b/Tests/RainmakerTests/Responses/31.0.14/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json @@ -1,5 +1,5 @@ { - "appPassword" : "gH4twSxNdaF63sRhhjjQ63beubkXct4PDf0UMlVxUiQkyV8aUoFNcZNV2NCToXvLYuFrWw2k", + "appPassword" : "REDACTED", "loginName" : "admin", "server" : "http://localhost" } diff --git a/Tests/RainmakerTests/Responses/32.0.11/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json b/Tests/RainmakerTests/Responses/32.0.11/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json new file mode 100644 index 0000000..a6e51f9 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"enabled":true,"storageLocation":"\/var\/www\/html\/data\/admin","id":"admin","firstLoginTimestamp": 946684800,"lastLoginTimestamp": 946684800,"lastLogin": 946684800000,"backend":"Database","subadmin":[],"quota":{"free": 0,"used": 0,"total": 0,"relative": 0,"quota":-3},"manager":"","avatarScope":"v2-federated","email":null,"emailScope":"v2-federated","additional_mail":[],"additional_mailScope":[],"displayname":"admin","display-name":"admin","displaynameScope":"v2-federated","phone":"","phoneScope":"v2-local","address":"","addressScope":"v2-local","website":"","websiteScope":"v2-local","twitter":"","twitterScope":"v2-local","bluesky":"","blueskyScope":"v2-local","fediverse":"","fediverseScope":"v2-local","organisation":"","organisationScope":"v2-local","role":"","roleScope":"v2-local","headline":"","headlineScope":"v2-local","biography":"","biographyScope":"v2-local","profile_enabled":"1","profile_enabledScope":"v2-local","pronouns":"","pronounsScope":"v2-federated","groups":["admin"],"language":"de_DE","locale":"","timezone":"","notify_email":null,"backendCapabilities":{"setDisplayName":true,"setPassword":true}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json b/Tests/RainmakerTests/Responses/32.0.11/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json index d34ef25..6070b3f 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json +++ b/Tests/RainmakerTests/Responses/32.0.11/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json @@ -1 +1 @@ -{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/EZSaQChchIvMPlM8g9kvzvKQUIEyVUVRbPcbwHWJgGrYabbKNRMzQq4nF2r23giwOyYQZX7OsDiqdNpFpXtpWleFHtL1YWszrAVxnLkT3b6qvQw5QDm5fJviTSTRv7Ep"} \ No newline at end of file +{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/REDACTED"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt index 482f8bd..ff634c5 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt +++ b/Tests/RainmakerTests/Responses/32.0.11/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt @@ -1,2 +1,2 @@ -HTTP/1.1 412 +HTTP/1.1 412 Precondition Failed Content-Type: application/xml; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/bfcc8d3a704d831b04471dd7ac563510.png/Body.xml b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/bfcc8d3a704d831b04471dd7ac563510.png/Body.xml new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/bfcc8d3a704d831b04471dd7ac563510.png/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/bfcc8d3a704d831b04471dd7ac563510.png/Headers.txt new file mode 100644 index 0000000..58e46ab --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/bfcc8d3a704d831b04471dd7ac563510.png/Headers.txt @@ -0,0 +1 @@ +HTTP/1.1 204 No Content diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..3a37e6d --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":11,"string":"32.0.11","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"}},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"32.0.0"},"circles":{"version":"32.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 32.0.11.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["\/invite-accepted","\/notifications","\/shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.22.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","federated-shared-items","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"play-sounds":false,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"22.0.18"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json new file mode 100644 index 0000000..5e8130a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json @@ -0,0 +1 @@ +{"filename":"bfcc8d3a704d831b04471dd7ac563510.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..b916194 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/154/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/154/Body.json new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt new file mode 100644 index 0000000..b8b7b7a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 405 Method Not Allowed +Content-Type: text/html; charset=UTF-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/154/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/154/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/154/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/154/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/154/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/154/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/b2fcd9bfdcf274d4792d99a907dffd28.png/Body.xml b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/b2fcd9bfdcf274d4792d99a907dffd28.png/Body.xml new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/b2fcd9bfdcf274d4792d99a907dffd28.png/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/b2fcd9bfdcf274d4792d99a907dffd28.png/Headers.txt new file mode 100644 index 0000000..58e46ab --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/DELETE/remote.php/dav/files/admin/Notizen/Rainmaker%20Tests/b2fcd9bfdcf274d4792d99a907dffd28.png/Headers.txt @@ -0,0 +1 @@ +HTTP/1.1 204 No Content diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/154/Body.bin b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/154/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/154/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..3a37e6d --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":11,"string":"32.0.11","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"}},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"32.0.0"},"circles":{"version":"32.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 32.0.11.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["\/invite-accepted","\/notifications","\/shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.22.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","federated-shared-items","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"play-sounds":false,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"22.0.18"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/154/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/154/Body.json new file mode 100644 index 0000000..45fe3eb --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/154/Body.json @@ -0,0 +1 @@ +{"filename":"b2fcd9bfdcf274d4792d99a907dffd28.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/154/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..22bb944 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":154,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/156/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/156/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/156/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/156/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/156/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/156/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/156/Body.bin b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/156/Body.bin new file mode 100644 index 0000000..21a593d --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/156/Body.bin @@ -0,0 +1 @@ +{"errorType":"OCP\\Files\\NotFoundException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/156/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/156/Headers.txt new file mode 100644 index 0000000..ee5eadf --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/156/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..0ee8474 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":156,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..8a03db6 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":148,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json new file mode 100644 index 0000000..8a03db6 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json @@ -0,0 +1 @@ +{"id":148,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt new file mode 100644 index 0000000..c3d700c --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 412 Precondition Failed +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..0428ebb --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..c4184d5 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":149,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json new file mode 100644 index 0000000..28a3c8f --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json @@ -0,0 +1 @@ +{"id":149,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":true,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0428ebb --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..1d4a3fd --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..6f312af --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Test","modified":1650000100,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Changed\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..f708e63 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..792b71a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Renamed","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Renamed.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..4252080 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":157,"title":"ABC","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/ABC.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..bffa72a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..bffa72a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json index 73ff48e..c2824b7 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":103,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":98,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":114,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 73ff48e..c2824b7 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":103,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":98,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":114,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..9949fdd --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":106,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..0428ebb --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/109/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/109/Body.json new file mode 100644 index 0000000..9cd71c1 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/109/Body.json @@ -0,0 +1 @@ +{"id":109,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/109/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/109/Headers.txt new file mode 100644 index 0000000..a03eb36 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/109/Headers.txt @@ -0,0 +1,4 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..1078dc3 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":109,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":117,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 3463159..47258fd 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":162},{"id":163}] \ No newline at end of file +[{"id":151},{"id":150}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..e21b925 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":111,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"},{"id":115,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..de02c96 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":111,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/107/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/107/Body.json new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/107/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/107/Headers.txt new file mode 100644 index 0000000..b535abc --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/107/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 304 Not Modified +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..01b3693 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":104,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..3a37e6d --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":32,"minor":0,"micro":11,"string":"32.0.11","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"}},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"32.0.0"},"circles":{"version":"32.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 32.0.11.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["\/invite-accepted","\/notifications","\/shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"5.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.22.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","federated-shared-items","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"play-sounds":false,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"]},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"22.0.18"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1 @@ +[] diff --git a/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..343df23 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..5f9f132 --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1,5 @@ +{ + "appPassword" : "REDACTED", + "loginName" : "admin", + "server" : "http://localhost" +} diff --git a/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..fb7791b --- /dev/null +++ b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json index 75a9f3b..5f9f132 100644 --- a/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json +++ b/Tests/RainmakerTests/Responses/32.0.11/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json @@ -1,5 +1,5 @@ { - "appPassword" : "gH4twSxNdaF63sRhhjjQ63beubkXct4PDf0UMlVxUiQkyV8aUoFNcZNV2NCToXvLYuFrWw2k", + "appPassword" : "REDACTED", "loginName" : "admin", "server" : "http://localhost" } diff --git a/Tests/RainmakerTests/Responses/33.0.5/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/33.0.5/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json index 20be44a..57b2774 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -1 +1 @@ -{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":33,"minor":0,"micro":5,"string":"33.0.5","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"33.0.0"},"circles":{"version":"33.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 33.0.5.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.23.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"23.0.10"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":33,"minor":0,"micro":5,"string":"33.0.5","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"33.0.0"},"circles":{"version":"33.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 33.0.5.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.23.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"23.0.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json b/Tests/RainmakerTests/Responses/33.0.5/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json new file mode 100644 index 0000000..a6e51f9 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"enabled":true,"storageLocation":"\/var\/www\/html\/data\/admin","id":"admin","firstLoginTimestamp": 946684800,"lastLoginTimestamp": 946684800,"lastLogin": 946684800000,"backend":"Database","subadmin":[],"quota":{"free": 0,"used": 0,"total": 0,"relative": 0,"quota":-3},"manager":"","avatarScope":"v2-federated","email":null,"emailScope":"v2-federated","additional_mail":[],"additional_mailScope":[],"displayname":"admin","display-name":"admin","displaynameScope":"v2-federated","phone":"","phoneScope":"v2-local","address":"","addressScope":"v2-local","website":"","websiteScope":"v2-local","twitter":"","twitterScope":"v2-local","bluesky":"","blueskyScope":"v2-local","fediverse":"","fediverseScope":"v2-local","organisation":"","organisationScope":"v2-local","role":"","roleScope":"v2-local","headline":"","headlineScope":"v2-local","biography":"","biographyScope":"v2-local","profile_enabled":"1","profile_enabledScope":"v2-local","pronouns":"","pronounsScope":"v2-federated","groups":["admin"],"language":"de_DE","locale":"","timezone":"","notify_email":null,"backendCapabilities":{"setDisplayName":true,"setPassword":true}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json b/Tests/RainmakerTests/Responses/33.0.5/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json index 6d3d62c..6070b3f 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json @@ -1 +1 @@ -{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/MSzHeEHbeflGr1hIDyUJPYxPHeLQrLwCeHFFGPpJyTykmXBSwMhaY4pWCWBisJyjhpMHhxrtHqPj8P93yRBW1MgG9xPglWH2KKlJezNw5adtFzqVjozvIkuRqdOOFvrb"} \ No newline at end of file +{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/REDACTED"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt index 482f8bd..ff634c5 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt +++ b/Tests/RainmakerTests/Responses/33.0.5/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt @@ -1,2 +1,2 @@ -HTTP/1.1 412 +HTTP/1.1 412 Precondition Failed Content-Type: application/xml; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..57b2774 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":33,"minor":0,"micro":5,"string":"33.0.5","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"33.0.0"},"circles":{"version":"33.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 33.0.5.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.23.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"23.0.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json new file mode 100644 index 0000000..fb560f7 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json @@ -0,0 +1 @@ +{"filename":".attachments.151\/Rainmaker.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..b916194 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Body.bin b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..57b2774 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":33,"minor":0,"micro":5,"string":"33.0.5","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"33.0.0"},"circles":{"version":"33.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 33.0.5.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.23.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"23.0.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Body.json new file mode 100644 index 0000000..fa37f28 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Body.json @@ -0,0 +1 @@ +{"filename":".attachments.155\/Rainmaker.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..49e448c --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Body.bin b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Body.bin new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Body.bin @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Headers.txt new file mode 100644 index 0000000..ee5eadf --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..7e248eb --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":158,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..8a03db6 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":148,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json new file mode 100644 index 0000000..8a03db6 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json @@ -0,0 +1 @@ +{"id":148,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt new file mode 100644 index 0000000..c3d700c --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 412 Precondition Failed +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..c4184d5 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":149,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json new file mode 100644 index 0000000..28a3c8f --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json @@ -0,0 +1 @@ +{"id":149,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":true,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..1d4a3fd --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..6f312af --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Test","modified":1650000100,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Changed\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..f708e63 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..792b71a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Renamed","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Renamed.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..4252080 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":157,"title":"ABC","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/ABC.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..64ef0dc --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..64ef0dc --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json index 73ff48e..3e34568 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":103,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":105,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 73ff48e..3e34568 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":103,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":105,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..e8cdaa4 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":100,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/101/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/101/Body.json new file mode 100644 index 0000000..2019818 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/101/Body.json @@ -0,0 +1 @@ +{"id":101,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/101/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/101/Headers.txt new file mode 100644 index 0000000..a03eb36 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/101/Headers.txt @@ -0,0 +1,4 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..f5e036d --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":101,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":107,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 1578047..9699c59 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":163},{"id":162}] \ No newline at end of file +[{"id":150},{"id":151}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..a807b42 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":98,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"},{"id":111,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..2ee05e0 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":98,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/115/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/115/Body.json new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/115/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/115/Headers.txt new file mode 100644 index 0000000..b535abc --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/115/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 304 Not Modified +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..c19fd35 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":112,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":115,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json index bffa72a..64ef0dc 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json @@ -1 +1 @@ -{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} \ No newline at end of file +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..57b2774 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":33,"minor":0,"micro":5,"string":"33.0.5","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"files":{"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"comments":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"33.0.0"},"circles":{"version":"33.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 33.0.5.1","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.23.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","recording-chunked-upload"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"permissions":{"max-default":254,"max-custom":255,"default":246}},"config-local":{"attachments":["allowed","folder"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","hello-v2-token-key","mode"],"experiments":["enabled"],"permissions":[]},"version":"23.0.11"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1 @@ +[] diff --git a/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..343df23 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..5f9f132 --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1,5 @@ +{ + "appPassword" : "REDACTED", + "loginName" : "admin", + "server" : "http://localhost" +} diff --git a/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..fb7791b --- /dev/null +++ b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json index 75a9f3b..5f9f132 100644 --- a/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json +++ b/Tests/RainmakerTests/Responses/33.0.5/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json @@ -1,5 +1,5 @@ { - "appPassword" : "gH4twSxNdaF63sRhhjjQ63beubkXct4PDf0UMlVxUiQkyV8aUoFNcZNV2NCToXvLYuFrWw2k", + "appPassword" : "REDACTED", "loginName" : "admin", "server" : "http://localhost" } diff --git a/Tests/RainmakerTests/Responses/34.0.0/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/34.0.0/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json index 4fe0a7a..2b0d6c0 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/CapabilitiesTests/fetchAuthenticated/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -1 +1 @@ -{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":34,"minor":0,"micro":0,"string":"34.0.0","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"34.0.0"},"circles":{"version":"34.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 34.0.0.12","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"files":{"comments":true,"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"locking":"1.0","api-feature-lock-type":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"minSearchStringLength":0,"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.0.2","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.24.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","react-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","conversation-presets","private-reply","conversation-tags","bot-features-api","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","conversation-presets","conversation-tags","recording-chunked-upload","bot-features-api"],"config":{"attachments":{"allowed":true,"conversation-subfolders":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","sort-order":"activity","group-mode":"none","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"feature-hints":{"current":34,"hidden":0},"permissions":{"max-default":510,"max-custom":511,"default":502}},"config-local":{"attachments":["allowed","folder","conversation-subfolders"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","sort-order","group-mode","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","mode","hello-v2-token-key"],"experiments":["enabled"],"feature-hints":["current","hidden"],"permissions":[]},"version":"24.0.4"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","imprintUrl":"","privacyUrl":"","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","primaryColor":"#00679e","backgroundColor":"#00679e","defaultPrimaryColor":"#00679e","defaultBackgroundColor":"#00679e","inverted":false,"cacheBuster":"e629f55f","enabledThemes":["default"]},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":34,"minor":0,"micro":0,"string":"34.0.0","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"34.0.0"},"circles":{"version":"34.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 34.0.0.12","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"files":{"comments":true,"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"locking":"1.0","api-feature-lock-type":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"minSearchStringLength":0,"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.24.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","react-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","conversation-presets","private-reply","conversation-tags","bot-features-api","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","conversation-presets","conversation-tags","recording-chunked-upload","bot-features-api"],"config":{"attachments":{"allowed":true,"conversation-subfolders":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","sort-order":"activity","group-mode":"none","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"feature-hints":{"current":34,"hidden":0},"permissions":{"max-default":510,"max-custom":511,"default":502}},"config-local":{"attachments":["allowed","folder","conversation-subfolders"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","sort-order","group-mode","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","mode","hello-v2-token-key"],"experiments":["enabled"],"feature-hints":["current","hidden"],"permissions":[]},"version":"24.0.5"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","imprintUrl":"","privacyUrl":"","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","primaryColor":"#00679e","backgroundColor":"#00679e","defaultPrimaryColor":"#00679e","defaultBackgroundColor":"#00679e","inverted":false,"cacheBuster":"e629f55f","enabledThemes":["default"]},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json b/Tests/RainmakerTests/Responses/34.0.0/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json new file mode 100644 index 0000000..a6e51f9 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"enabled":true,"storageLocation":"\/var\/www\/html\/data\/admin","id":"admin","firstLoginTimestamp": 946684800,"lastLoginTimestamp": 946684800,"lastLogin": 946684800000,"backend":"Database","subadmin":[],"quota":{"free": 0,"used": 0,"total": 0,"relative": 0,"quota":-3},"manager":"","avatarScope":"v2-federated","email":null,"emailScope":"v2-federated","additional_mail":[],"additional_mailScope":[],"displayname":"admin","display-name":"admin","displaynameScope":"v2-federated","phone":"","phoneScope":"v2-local","address":"","addressScope":"v2-local","website":"","websiteScope":"v2-local","twitter":"","twitterScope":"v2-local","bluesky":"","blueskyScope":"v2-local","fediverse":"","fediverseScope":"v2-local","organisation":"","organisationScope":"v2-local","role":"","roleScope":"v2-local","headline":"","headlineScope":"v2-local","biography":"","biographyScope":"v2-local","profile_enabled":"1","profile_enabledScope":"v2-local","pronouns":"","pronounsScope":"v2-federated","groups":["admin"],"language":"de_DE","locale":"","timezone":"","notify_email":null,"backendCapabilities":{"setDisplayName":true,"setPassword":true}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/CurrentUserTests/fetch/GET/ocs/v2.php/cloud/user/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json b/Tests/RainmakerTests/Responses/34.0.0/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json index 6788018..6070b3f 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/LoginTests/fetchLoginInformation/POST/index.php/login/v2/Body.json @@ -1 +1 @@ -{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/zFowLHY8OgDOkk2S5or7xPIHNv143Rm3ZIiAprLrM4PeuX6rs4oYmxS0pBfCp9nVpuBCDQq1SXZsm7JRzZALGfXLxZnrGFdppxY0b7G2bBlJtSeaFAA9OiHKaTPY61FW"} \ No newline at end of file +{"poll":{"token": "REDACTED","endpoint":"http:\/\/localhost\/login\/v2\/poll"},"login":"http:\/\/localhost\/login\/v2\/flow\/REDACTED"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt index 482f8bd..ff634c5 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt +++ b/Tests/RainmakerTests/Responses/34.0.0/MoveTests/conflictWhenExists/MOVE/remote.php/dav/files/admin/Readme.md/Headers.txt @@ -1,2 +1,2 @@ -HTTP/1.1 412 +HTTP/1.1 412 Precondition Failed Content-Type: application/xml; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..2b0d6c0 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":34,"minor":0,"micro":0,"string":"34.0.0","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"34.0.0"},"circles":{"version":"34.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 34.0.0.12","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"files":{"comments":true,"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"locking":"1.0","api-feature-lock-type":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"minSearchStringLength":0,"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.24.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","react-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","conversation-presets","private-reply","conversation-tags","bot-features-api","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","conversation-presets","conversation-tags","recording-chunked-upload","bot-features-api"],"config":{"attachments":{"allowed":true,"conversation-subfolders":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","sort-order":"activity","group-mode":"none","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"feature-hints":{"current":34,"hidden":0},"permissions":{"max-default":510,"max-custom":511,"default":502}},"config-local":{"attachments":["allowed","folder","conversation-subfolders"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","sort-order","group-mode","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","mode","hello-v2-token-key"],"experiments":["enabled"],"feature-hints":["current","hidden"],"permissions":[]},"version":"24.0.5"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","imprintUrl":"","privacyUrl":"","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","primaryColor":"#00679e","backgroundColor":"#00679e","defaultPrimaryColor":"#00679e","defaultBackgroundColor":"#00679e","inverted":false,"cacheBuster":"e629f55f","enabledThemes":["default"]},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json new file mode 100644 index 0000000..fb560f7 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Body.json @@ -0,0 +1 @@ +{"filename":".attachments.151\/Rainmaker.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1.4/attachment/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..b916194 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/download/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Body.bin b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Body.bin new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Body.bin differ diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt new file mode 100644 index 0000000..f08a359 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: image/png diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..2b0d6c0 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":34,"minor":0,"micro":0,"string":"34.0.0","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"34.0.0"},"circles":{"version":"34.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 34.0.0.12","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"files":{"comments":true,"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"locking":"1.0","api-feature-lock-type":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"minSearchStringLength":0,"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.24.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","react-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","conversation-presets","private-reply","conversation-tags","bot-features-api","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","conversation-presets","conversation-tags","recording-chunked-upload","bot-features-api"],"config":{"attachments":{"allowed":true,"conversation-subfolders":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","sort-order":"activity","group-mode":"none","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"feature-hints":{"current":34,"hidden":0},"permissions":{"max-default":510,"max-custom":511,"default":502}},"config-local":{"attachments":["allowed","folder","conversation-subfolders"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","sort-order","group-mode","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","mode","hello-v2-token-key"],"experiments":["enabled"],"feature-hints":["current","hidden"],"permissions":[]},"version":"24.0.5"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","imprintUrl":"","privacyUrl":"","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","primaryColor":"#00679e","backgroundColor":"#00679e","defaultPrimaryColor":"#00679e","defaultBackgroundColor":"#00679e","inverted":false,"cacheBuster":"e629f55f","enabledThemes":["default"]},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Body.json new file mode 100644 index 0000000..fa37f28 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Body.json @@ -0,0 +1 @@ +{"filename":".attachments.155\/Rainmaker.png"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1.4/attachment/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..49e448c --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/DELETE/index.php/apps/notes/api/v1/notes/158/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Body.bin b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Body.bin new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Body.bin @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Headers.txt new file mode 100644 index 0000000..ee5eadf --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/GET/index.php/apps/notes/api/v1.4/attachment/158/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..7e248eb --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":158,"title":"Rainmaker Attachment Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Attachment Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteAttachmentTests/missingAttachment/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/DELETE/index.php/apps/notes/api/v1/notes/148/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..8a03db6 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":148,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json new file mode 100644 index 0000000..8a03db6 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Body.json @@ -0,0 +1 @@ +{"id":148,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt new file mode 100644 index 0000000..c3d700c --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/conflict/PUT/index.php/apps/notes/api/v1/notes/148/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 412 Precondition Failed +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/deleteMissing/DELETE/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/DELETE/index.php/apps/notes/api/v1/notes/149/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..c4184d5 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":149,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json new file mode 100644 index 0000000..28a3c8f --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Body.json @@ -0,0 +1 @@ +{"id":149,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":true,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/favorite/PUT/index.php/apps/notes/api/v1/notes/149/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/DELETE/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/GET/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..1d4a3fd --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Test","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json new file mode 100644 index 0000000..6f312af --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Body.json @@ -0,0 +1 @@ +{"id":151,"title":"Rainmaker Test","modified":1650000100,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Changed\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/lifecycle/PUT/index.php/apps/notes/api/v1/notes/151/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/DELETE/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..f708e63 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Test","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Test.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json new file mode 100644 index 0000000..792b71a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Body.json @@ -0,0 +1 @@ +{"id":155,"title":"Rainmaker Renamed","modified":1650000000,"category":"Rainmaker Tests","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker Tests\/Rainmaker Renamed.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/rename/PUT/index.php/apps/notes/api/v1/notes/155/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Body.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/DELETE/index.php/apps/notes/api/v1/notes/157/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..4252080 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +{"id":157,"title":"ABC","modified":1650000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/ABC.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Test\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/sanitizedTitle/POST/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..64ef0dc --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/GET/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json new file mode 100644 index 0000000..64ef0dc --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Body.json @@ -0,0 +1 @@ +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt new file mode 100644 index 0000000..73ed12a --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NoteMutationTests/updateSettings/PUT/index.php/apps/notes/api/v1/settings/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json index 493212e..749c90b 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":98,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":110,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":102,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":117,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetch/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 493212e..749c90b 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":98,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"},{"id":110,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file +[{"id":102,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":117,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchEverythingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..478c364 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":113,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json new file mode 100644 index 0000000..5b6a92d --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Body.json @@ -0,0 +1 @@ +{"errorType":"Exception"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt new file mode 100644 index 0000000..7dd116f --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchMissingNote/GET/index.php/apps/notes/api/v1/notes/999999999/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNone/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/105/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/105/Body.json new file mode 100644 index 0000000..33416d2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/105/Body.json @@ -0,0 +1 @@ +{"id":105,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/105/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/105/Headers.txt new file mode 100644 index 0000000..a03eb36 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/105/Headers.txt @@ -0,0 +1,4 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..1fa5357 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":105,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":115,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json index 1578047..47258fd 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -1 +1 @@ -[{"id":163},{"id":162}] \ No newline at end of file +[{"id":151},{"id":150}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt index 73ed12a..572d263 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchNothingChanged/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -1,3 +1,5 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..c0317be --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":98,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"},{"id":108,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummaries/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..02b03f3 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":98,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..41d4381 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchSummariesFirstChunk/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 +X-Notes-Chunk-Cursor: 946684800-946684800-0 +X-Notes-Chunk-Pending: 1 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/108/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/108/Body.json new file mode 100644 index 0000000..e69de29 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/108/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/108/Headers.txt new file mode 100644 index 0000000..b535abc --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/108/Headers.txt @@ -0,0 +1,3 @@ +HTTP/1.1 304 Not Modified +ETag: "00000000000000000000000000000000" +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json new file mode 100644 index 0000000..d39ed57 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Body.json @@ -0,0 +1 @@ +[{"id":108,"title":"Rainmaker","modified":1700000000,"category":"","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Rainmaker.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Rainmaker\n","etag": "00000000000000000000000000000000"},{"id":112,"title":"Pancakes","modified":1600000000,"category":"Recipes","favorite":false,"readonly":false,"internalPath":"\/Notizen\/Recipes\/Pancakes.md","shareTypes":[],"isShared":false,"error":false,"errorType":"","content":"# Pancakes\n","etag": "00000000000000000000000000000000"}] \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt new file mode 100644 index 0000000..572d263 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/fetchUnchangedNote/GET/index.php/apps/notes/api/v1/notes/Headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +ETag: "00000000000000000000000000000000" +Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT +X-Notes-API-Versions: 0.2, 1.3, 1.4 diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json index bffa72a..64ef0dc 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/index.php/apps/notes/api/v1/settings/Body.json @@ -1 +1 @@ -{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich"} \ No newline at end of file +{"notesPath":"Notizen","fileSuffix":".md","noteMode":"rich","showHidden":false,"loadRecentOnStartUp":true} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json new file mode 100644 index 0000000..2b0d6c0 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Body.json @@ -0,0 +1 @@ +{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"version":{"major":34,"minor":0,"micro":0,"string":"34.0.0","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)","mod-rewrite-working":true,"user":{"language":"","locale":"","timezone":"UTC"},"can-create-app-token":true},"bruteforce":{"delay":0,"allow-listed":false},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"app_api":{"loglevel":2,"version":"34.0.0"},"circles":{"version":"34.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Einzeln","2":"Pers\u00f6nlich","4":"System","8":"Sichtbar","16":"Offen","32":"Einladen","64":"Beitrittsanfrage","128":"Freunde","256":"Passwortgesch\u00fctzt","512":"Kein Besitzer","1024":"Versteckt","2048":"Backend","4096":"Lokal","8192":"Root","16384":"Teameinladung","32768":"Federated","65536":"Einh\u00e4ngepunkt"},"source":{"core":{"1":"Nextcloud-Konto","2":"Nextcloud-Gruppe","4":"E-Mail-Adresse","8":"Kontakt","16":"Team","10000":"Nextcloud-App"},"extra":{"10001":"Teams-App","10002":"Administrations-Befehlszeile"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Mitglied","4":"Moderator","8":"Administration","9":"Eigent\u00fcmer"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}},"teamResourceProviders":["files","talk"]},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","version":"1.1.0","endPoint":"http:\/\/localhost\/ocm","publicKey":null,"provider":"Nextcloud 34.0.0.12","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}],"capabilities":["invite-accepted","notifications","shares"]},"files":{"comments":true,"$comment":"\"blacklisted_files\" is deprecated as of Nextcloud 30, use \"forbidden_filenames\" instead","blacklisted_files":[".htaccess"],"forbidden_filenames":[".htaccess"],"forbidden_filename_basenames":[],"forbidden_filename_characters":["\\","\/"],"forbidden_filename_extensions":[".filepart",".part"],"bigfilechunking":true,"chunked_upload":{"max_size":104857600,"max_parallel_count":5},"file_conversions":[],"windows_compatible_filenames":false,"directEditing":{"url":"http:\/\/localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag": "00000000000000000000000000000000","supportsFileId":true},"locking":"1.0","api-feature-lock-type":true,"undelete":true,"delete_from_trash":true,"versioning":true,"version_labeling":true,"version_deletion":true},"dav":{"chunking":"1.0","public_shares_chunking":true,"search_supports_creation_time":true,"search_supports_upload_time":true,"search_supports_last_activity":true,"bulkupload":"1.0","absence-supported":true,"absence-replacement":true},"downloadlimit":{"enabled":true,"default-limit":-1},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":false},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true,"custom_tokens":false},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"exclude_reshare_from_edit":false,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"minSearchStringLength":0,"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":false}}},"notes":{"api_version":["0.2","1.3","1.4"],"version":"6.1.0","notes_path":"Notizen"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists","test-push"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"provisioning_api":{"version":"1.24.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":false},"recommendations":{"enabled":true},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","react-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","delete-messages-unlimited","edit-messages","silent-send-state","chat-read-last","federation-v1","federation-v2","ban-v1","chat-reference-id","mention-permissions","edit-messages-note-to-self","archived-conversations-v2","talk-polls-drafts","download-call-participants","email-csv-import","conversation-creation-password","call-notification-state-api","schedule-meeting","edit-draft-poll","conversation-creation-all","important-conversations","unbind-conversation","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","threads","pinned-messages","federated-shared-items","scheduled-messages","conversation-presets","private-reply","conversation-tags","bot-features-api","reactions","recording-chunked-upload"],"features-local":["favorites","chat-read-status","listable-rooms","phonebook-search","temp-user-avatar-api","unified-search","avatar","remind-me-later","note-to-self","archived-conversations-v2","chat-summary-api","call-notification-state-api","schedule-meeting","conversation-creation-all","important-conversations","sip-direct-dialin","dashboard-event-rooms","mutual-calendar-events","upcoming-reminders","sensitive-conversations","scheduled-messages","conversation-presets","conversation-tags","recording-chunked-upload","bot-features-api"],"config":{"attachments":{"allowed":true,"conversation-subfolders":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4b","\ud83d\udc4d","\ud83d\udc4e","\ud83d\udd25","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"can-upload-background":true,"sip-enabled":false,"sip-dialout-enabled":false,"default-phone-region":"","can-enable-sip":true,"start-without-media":false,"max-duration":0,"blur-virtual-background":false,"end-to-end-encryption":false,"live-transcription":false,"live-translation":false,"play-sounds":true,"grid-limit":19,"grid-limit-enforced":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"predefined-backgrounds-v2":["\/custom_apps\/spreed\/img\/backgrounds\/1_office.jpg","\/custom_apps\/spreed\/img\/backgrounds\/2_home.jpg","\/custom_apps\/spreed\/img\/backgrounds\/3_abstract.jpg","\/custom_apps\/spreed\/img\/backgrounds\/4_beach.jpg","\/custom_apps\/spreed\/img\/backgrounds\/5_park.jpg","\/custom_apps\/spreed\/img\/backgrounds\/6_theater.jpg","\/custom_apps\/spreed\/img\/backgrounds\/7_library.jpg","\/custom_apps\/spreed\/img\/backgrounds\/8_space_station.jpg"],"live-transcription-target-language-id":""},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"has-translation-task-providers":false,"typing-privacy":0,"summary-threshold":100,"style":"split","matterbridge-enabled":false},"conversations":{"can-create":true,"force-passwords":false,"list-style":"two-lines","sort-order":"activity","group-mode":"none","description-length":2000,"retention-event":28,"retention-phone":7,"retention-instant-meetings":1},"federation":{"enabled":false,"incoming-enabled":false,"outgoing-enabled":false,"only-trusted-servers":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"mode":"internal","hello-v2-token-key": "REDACTED"},"experiments":{"enabled":0},"feature-hints":{"current":34,"hidden":0},"permissions":{"max-default":510,"max-custom":511,"default":502}},"config-local":{"attachments":["allowed","folder","conversation-subfolders"],"call":["predefined-backgrounds","predefined-backgrounds-v2","can-upload-background","start-without-media","blur-virtual-background","live-transcription-target-language-id","play-sounds","grid-limit","grid-limit-enforced","external-call-service"],"chat":["read-privacy","has-translation-providers","has-translation-task-providers","typing-privacy","summary-threshold","style","matterbridge-enabled"],"conversations":["can-create","list-style","sort-order","group-mode","description-length"],"federation":["enabled","incoming-enabled","outgoing-enabled","only-trusted-servers"],"previews":["max-gif-size"],"signaling":["session-ping-limit","mode","hello-v2-token-key"],"experiments":["enabled"],"feature-hints":["current","hidden"],"permissions":[]},"version":"24.0.5"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","productName":"Nextcloud","url":"https:\/\/nextcloud.com","imprintUrl":"","privacyUrl":"","slogan":"ein sicherer Ort f\u00fcr all Ihre Daten","color":"#00679e","color-text":"#ffffff","color-element":"#00679e","color-element-bright":"#00679e","color-element-dark":"#00679e","logo":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","background":"http:\/\/localhost\/apps\/theming\/img\/background\/jo-myoung-hee-fluid.webp","background-text":"#ffffff","background-plain":false,"background-default":true,"logoheader":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","favicon":"http:\/\/localhost\/core\/img\/logo\/logo.svg?v=0","primaryColor":"#00679e","backgroundColor":"#00679e","defaultPrimaryColor":"#00679e","defaultBackgroundColor":"#00679e","inverted":false,"cacheBuster":"e629f55f","enabledThemes":["default"]},"user_status":{"enabled":true,"restore":true,"supports_emoji":true,"supports_busy":true},"weather_status":{"enabled":true}}}}} \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt new file mode 100644 index 0000000..e18b7e2 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/NotesTests/settings/GET/ocs/v2.php/cloud/capabilities/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 diff --git a/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingPending/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1 @@ +[] diff --git a/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..343df23 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingPending/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 404 Not Found +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json new file mode 100644 index 0000000..5f9f132 --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingResult/POST/index.php/login/v2/poll/Body.json @@ -0,0 +1,5 @@ +{ + "appPassword" : "REDACTED", + "loginName" : "admin", + "server" : "http://localhost" +} diff --git a/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt new file mode 100644 index 0000000..fb7791b --- /dev/null +++ b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingResult/POST/index.php/login/v2/poll/Headers.txt @@ -0,0 +1,2 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json index 75a9f3b..5f9f132 100644 --- a/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json +++ b/Tests/RainmakerTests/Responses/34.0.0/PollTests/pollingSuccess/POST/index.php/login/v2/poll/Body.json @@ -1,5 +1,5 @@ { - "appPassword" : "gH4twSxNdaF63sRhhjjQ63beubkXct4PDf0UMlVxUiQkyV8aUoFNcZNV2NCToXvLYuFrWw2k", + "appPassword" : "REDACTED", "loginName" : "admin", "server" : "http://localhost" } diff --git a/Tests/RainmakerTests/ServerEventsTests.swift b/Tests/RainmakerTests/ServerEventsTests.swift index c99fe51..6c177db 100644 --- a/Tests/RainmakerTests/ServerEventsTests.swift +++ b/Tests/RainmakerTests/ServerEventsTests.swift @@ -10,6 +10,7 @@ import Testing /// End-to-end behavior of ``Serving/events(_:)``: transport selection, frame delivery, polling fallback, reconnection, and authentication failures. /// /// These drive the coordinator with mock transports and tiny timing so no network or live server is involved. +/// The tests which expect a WebSocket to be opened are enabled only where ``ServerEventCoordinator/platformSupportsWebSocket`` is `true`, because the automatic transport polls on watchOS, which a test of its own covers there. /// @Suite("Server Events") struct ServerEventsTests { // MARK: - Fixtures @@ -42,14 +43,14 @@ import Testing /// /// Build the event stream through a coordinator configured with tiny retry and backoff timing so the tests run quickly. /// - private func makeStream(server: Server, options: ServerEventOptions, pingInterval: TimeInterval = 30, pongTimeout: TimeInterval = 10) -> AsyncThrowingStream { + private func makeStream(server: Server, options: ServerEventOptions, pingInterval: TimeInterval = 30, pongTimeout: TimeInterval = 10, rediscoverInterval: TimeInterval = 0.3, maximumConnectionFailures: Int = 3) -> AsyncThrowingStream { AsyncThrowingStream { continuation in guard server.user != nil, server.password != nil else { continuation.finish(throwing: RainmakerError.credentialsRequired) return } - let coordinator = ServerEventCoordinator(server: server, options: options, logger: Logger(subsystem: "RainmakerTests", category: "ServerEvents"), maximumAuthenticationAttempts: 2, authenticationRetryInterval: 0.02, rediscoverInterval: 0.3, backoffCeiling: 0.05, initialBackoff: 0.01, pingInterval: pingInterval, pongTimeout: pongTimeout) + let coordinator = ServerEventCoordinator(server: server, options: options, logger: Logger(subsystem: "RainmakerTests", category: "ServerEvents"), maximumAuthenticationAttempts: 2, maximumConnectionFailures: maximumConnectionFailures, authenticationRetryInterval: 0.02, rediscoverInterval: rediscoverInterval, backoffCeiling: 0.05, initialBackoff: 0.01, pingInterval: pingInterval, pongTimeout: pongTimeout) let task = Task { await coordinator.run(into: continuation) } @@ -92,7 +93,7 @@ import Testing #expect(events == [.connected, .notifications, .notifications]) } - @Test("Delivers Pushed Notifications") + @Test("Delivers Pushed Notifications", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func deliversPushedNotifications() async throws { let channel = MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")]) let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: MockWebSocketConnecting(channels: [channel])) @@ -103,7 +104,7 @@ import Testing #expect(await channel.sentFrames() == ["admin", "admin"]) } - @Test("Delivers Pushed File Identifiers") + @Test("Delivers Pushed File Identifiers", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func deliversPushedFileIdentifiers() async throws { let channel = MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_file_id [10,20]")]) let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["files"])), webSocket: MockWebSocketConnecting(channels: [channel])) @@ -114,7 +115,7 @@ import Testing #expect(await channel.sentFrames() == ["admin", "admin", "listen notify_file_id"]) } - @Test("Reconnects And Reconciles") + @Test("Reconnects And Reconciles", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func reconnectsAndReconciles() async throws { let first = MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")], closesWhenExhausted: true) let second = MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")]) @@ -126,7 +127,7 @@ import Testing #expect(events == [.connected, .notifications, .connected, .notifications]) } - @Test("Closes The Socket When The Consumer Stops") + @Test("Closes The Socket When The Consumer Stops", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func closesTheSocketWhenTheConsumerStops() async throws { let channel = MockWebSocketChannel(frames: [.text("authenticated")], ignoresTaskCancellation: true) let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: MockWebSocketConnecting(channels: [channel])) @@ -146,7 +147,7 @@ import Testing #expect(try await eventually { channel.closure.isClosed }) } - @Test("Closes The Socket When The Consumer Stops During Authentication") + @Test("Closes The Socket When The Consumer Stops During Authentication", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func closesTheSocketWhenTheConsumerStopsDuringAuthentication() async throws { let channel = MockWebSocketChannel(frames: [], ignoresTaskCancellation: true) let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: MockWebSocketConnecting(channels: [channel])) @@ -162,7 +163,7 @@ import Testing #expect(try await eventually { channel.closure.isClosed }) } - @Test("Reconnects When A Pong Never Arrives") + @Test("Reconnects When A Pong Never Arrives", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func reconnectsWhenAPongNeverArrives() async throws { let silent = MockWebSocketChannel(frames: [.text("authenticated")], ignoresTaskCancellation: true, pingBehavior: .never) let answering = MockWebSocketChannel(frames: [.text("authenticated")]) @@ -175,7 +176,7 @@ import Testing #expect(silent.closure.isClosed) } - @Test("Reconnects When A Pong Never Arrives Although The Ping Ignores Cancellation") + @Test("Reconnects When A Pong Never Arrives Although The Ping Ignores Cancellation", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func reconnectsWhenAPongNeverArrivesAlthoughThePingIgnoresCancellation() async throws { let silent = MockWebSocketChannel(frames: [.text("authenticated")], ignoresTaskCancellation: true, pingBehavior: .neverIgnoringCancellation) let answering = MockWebSocketChannel(frames: [.text("authenticated")]) @@ -188,7 +189,7 @@ import Testing #expect(silent.closure.isClosed) } - @Test("Keeps The Socket Open While Pongs Arrive") + @Test("Keeps The Socket Open While Pongs Arrive", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func keepsTheSocketOpenWhilePongsArrive() async throws { let channel = MockWebSocketChannel(frames: [.text("authenticated")]) let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: MockWebSocketConnecting(channels: [channel])) @@ -211,7 +212,7 @@ import Testing #expect(events.get() == [.connected]) } - @Test("Falls Back To Polling After Repeated Auth Rejection") + @Test("Falls Back To Polling After Repeated Auth Rejection", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func fallsBackToPollingAfterAuthRejection() async throws { let rejecting = { MockWebSocketChannel(frames: [.text("err: Invalid credentials")], closesWhenExhausted: true) } let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: MockWebSocketConnecting(channels: [rejecting(), rejecting()])) @@ -222,7 +223,7 @@ import Testing #expect(events == [.connected, .notifications]) } - @Test("Ends On Unauthorized Discovery") + @Test("Ends On Unauthorized Discovery", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) func endsOnUnauthorizedDiscovery() async throws { let server = makeServer(session: MockRequesting(string: "unauthorized", statusCode: 401), webSocket: MockWebSocketConnecting(channels: [])) let stream = makeStream(server: server, options: ServerEventOptions(subjects: [.notifications], emitConnectedOnStart: false)) @@ -232,6 +233,83 @@ import Testing } } + @Test("Falls Back To Polling After Repeated Connection Failures", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) + func fallsBackToPollingAfterRepeatedConnectionFailures() async throws { + let failing = { MockWebSocketChannel(frames: [], closesWhenExhausted: true) } + let connector = MockWebSocketConnecting(channels: [failing(), failing(), failing()]) + let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: connector) + let stream = makeStream(server: server, options: ServerEventOptions(subjects: [.notifications], pollInterval: 0.03), rediscoverInterval: 30) + + // Every socket drops before it authenticates, so after the third failure the coordinator polls instead of reconnecting forever, and the long window keeps it from trying the socket again while the test looks. + let events = try await firstEvents(2, from: stream) + #expect(events == [.connected, .notifications]) + #expect(connector.openedChannelCount == 3) + } + + @Test("Authenticated Connection Resets The Connection Failure Count", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) + func authenticatedConnectionResetsTheConnectionFailureCount() async throws { + let failing = { MockWebSocketChannel(frames: [], closesWhenExhausted: true) } + let dropping = MockWebSocketChannel(frames: [.text("authenticated")], closesWhenExhausted: true) + let delivering = MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")]) + let connector = MockWebSocketConnecting(channels: [failing(), failing(), dropping, failing(), failing(), delivering]) + let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: connector) + + // The poll interval is far above the timeout of the test, so falling back to polling would stall the stream rather than deliver a hint which could pass for a pushed one. + let stream = makeStream(server: server, options: ServerEventOptions(subjects: [.notifications], pollInterval: 100, emitConnectedOnStart: false), rediscoverInterval: 100) + + // Two failures precede and follow the connection which authenticated, so only resetting the count there keeps the coordinator on the socket. + let events = try await firstEvents(3, from: stream) + #expect(events == [.connected, .connected, .notifications]) + #expect(connector.openedChannelCount == 6) + } + + @Test("An Authentication Rejection Resets The Connection Failure Count", .enabled(if: ServerEventCoordinator.platformSupportsWebSocket)) + func authenticationRejectionResetsTheConnectionFailureCount() async throws { + let failing = { MockWebSocketChannel(frames: [], closesWhenExhausted: true) } + let rejecting = MockWebSocketChannel(frames: [.text("err: Invalid credentials")], closesWhenExhausted: true) + let delivering = MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")]) + let connector = MockWebSocketConnecting(channels: [failing(), failing(), rejecting, failing(), failing(), delivering]) + let server = makeServer(session: MockRequesting(string: capabilities(pushing: ["notifications"])), webSocket: connector) + + // The poll interval is far above the timeout of the test, so falling back to polling would stall the stream rather than deliver a hint which could pass for a pushed one. + let stream = makeStream(server: server, options: ServerEventOptions(subjects: [.notifications], pollInterval: 100, emitConnectedOnStart: false), rediscoverInterval: 100) + + // The rejected socket reached the server, so the failures before and after it are not three in a row and the coordinator stays on the socket. + let events = try await firstEvents(2, from: stream) + #expect(events == [.connected, .notifications]) + #expect(connector.openedChannelCount == 6) + } + + @Test("Polls Only When Asked To") + func pollsOnlyWhenAskedTo() async throws { + let session = MockRequesting(string: capabilities(pushing: ["notifications"])) + let connector = MockWebSocketConnecting(channels: [MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")])]) + let server = makeServer(session: session, webSocket: connector) + let stream = makeStream(server: server, options: ServerEventOptions(subjects: [.notifications], pollInterval: 0.03, transport: .polling)) + + // The server advertises notify_push, but the polling transport neither asks for the capabilities nor opens a socket. + let events = try await firstEvents(3, from: stream) + #expect(events == [.connected, .notifications, .notifications]) + #expect(session.requests.isEmpty) + #expect(connector.openedChannelCount == 0) + } + + #if os(watchOS) + @Test("Automatic Transport Polls On watchOS") + func automaticTransportPollsOnWatchOS() async throws { + let session = MockRequesting(string: capabilities(pushing: ["notifications"])) + let connector = MockWebSocketConnecting(channels: [MockWebSocketChannel(frames: [.text("authenticated"), .text("notify_notification")])]) + let server = makeServer(session: session, webSocket: connector) + let stream = makeStream(server: server, options: ServerEventOptions(subjects: [.notifications], pollInterval: 0.03)) + + // The server advertises notify_push, but watchOS does not let the app rely on a socket, so the automatic transport polls without asking for the capabilities. + let events = try await firstEvents(3, from: stream) + #expect(events == [.connected, .notifications, .notifications]) + #expect(session.requests.isEmpty) + #expect(connector.openedChannelCount == 0) + } + #endif + @Test("Ends Without Credentials") func endsWithoutCredentials() async throws { let server = makeServer(session: MockRequesting(string: capabilities(pushing: nil)), webSocket: MockWebSocketConnecting(channels: []), authenticated: false) diff --git a/Tests/RainmakerTests/SingleNoteRequestTests.swift b/Tests/RainmakerTests/SingleNoteRequestTests.swift new file mode 100644 index 0000000..a3b2ae7 --- /dev/null +++ b/Tests/RainmakerTests/SingleNoteRequestTests.swift @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker +import Testing + +/// +/// About how ``Server/note(_:)`` and ``Server/note(_:ifChangedFrom:)`` build their request, how the latter asks for and reports a note which did not change, and how the statuses of a lookup map onto ``RainmakerError``. +/// +/// These tests deliberately do not use the fixture tree, for the reasons ``NotesRequestTests`` and ``NoteChangesRequestTests`` give: ``URLTestSession`` ignores request headers, so it cannot prove that a conditional request was made, and a live baseline cannot be made to produce every status the notes app may answer a lookup with. The recorded counterparts are in ``NotesTests``. A capturing ``MockRequesting`` is used instead. +/// +@Suite("Single Note Requests") struct SingleNoteRequestTests { + let serverAddress = URL(string: "http://localhost/")! + + /// + /// A response body with a single note as the notes app sends it for a lookup. + /// + let payload = #"{"id":7,"etag":"9cf1","readonly":false,"modified":1700000000,"title":"Rainmaker","category":"Work","content":"text","favorite":true,"error":false,"errorType":"","internalPath":"/Notes/Work/Rainmaker.md","shareTypes":[],"isShared":false}"# + + /// + /// The headers of a lookup as the notes app sends them, with the version header every supported response has to carry and the note's entity tag quoted. + /// + let supportedHeaders = [ + "X-Notes-API-Versions": "0.2, 1.3, 1.4", + "ETag": #""9cf1""#, + ] + + /// + /// Build a server whose session is the given mock. + /// + private func makeServer(session: MockRequesting) -> Server { + Server(address: serverAddress, password: "admin", user: "admin", session: session, userAgent: "RainmakerTests") + } + + // MARK: - Unconditional Lookup + + @Test("Targets The Note By Its Identifier") + func targetsNote() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + let note = try await makeServer(session: session).note(7) + + let request = try #require(session.requests.first) + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + // The identifier is part of the path, the lookup carries no query, and it is never answered from a local cache. + #expect(session.requests.count == 1) + #expect(request.httpMethod == "GET") + #expect(components.path == "/index.php/apps/notes/api/v1/notes/7") + #expect(components.queryItems == nil) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Basic ") == true) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + #expect(note.id == 7) + #expect(note.entityTag == "9cf1") + #expect(note.title == "Rainmaker") + #expect(note.category == "Work") + #expect(note.isFavorite) + #expect(note.path == "/Notes/Work/Rainmaker.md") + #expect(note.modification == Date(timeIntervalSince1970: 1_700_000_000)) + } + + @Test("A Missing Note Is Not Found") + func missingNote() async throws { + let session = MockRequesting(string: #"{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"}"#, statusCode: 404, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + + // The notes app answers this for an identifier it never assigned, for a deleted note and for a file which is not a note alike, and it adds its header to the answer. + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).note(7) + } + } + + @Test("An Unavailable App Is Not Mistaken For A Missing Note") + func unavailableApp() async throws { + let session = MockRequesting(string: "Not found", statusCode: 404, headerFields: [:]) + + // Without the header the route itself does not exist, which a client keeping its own copy must not take as a deleted note. + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await makeServer(session: session).note(7) + } + + await #expect(throws: RainmakerError.appUnavailable(app: "notes")) { + _ = try await makeServer(session: session).note(7, ifChangedFrom: "9cf1") + } + } + + @Test("An Outdated App Is Rejected") + func outdatedApp() async throws { + let session = MockRequesting(string: payload, headerFields: ["X-Notes-API-Versions": "0.2, 1.3"]) + + await #expect(throws: RainmakerError.unsupportedAPIVersion(app: "notes", required: "1.4", advertised: ["0.2", "1.3"])) { + _ = try await makeServer(session: session).note(7) + } + } + + @Test("A Success Without A Note Is A Decoding Failure") + func malformedNote() async throws { + let session = MockRequesting(string: "[]", headerFields: supportedHeaders) + + await #expect { + _ = try await makeServer(session: session).note(7) + } throws: { error in + guard case RainmakerError.responseDecodingFailed = error else { + return false + } + + return true + } + } + + @Test("Not Modified Is Unexpected For An Unconditional Lookup") + func unexpectedNotModified() async throws { + let session = MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + + // Only a conditional request can be answered with not modified, so anything else answering it is not what was asked for. + await #expect(throws: RainmakerError.unexpectedStatus(code: 304)) { + _ = try await makeServer(session: session).note(7) + } + } + + @Test("A Lookup Requires Credentials") + func requiresCredentials() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + let server = Server(address: serverAddress, session: session, userAgent: "RainmakerTests") + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.note(7) + } + + await #expect(throws: RainmakerError.credentialsRequired) { + _ = try await server.note(7, ifChangedFrom: "9cf1") + } + + // Nothing is sent without credentials. + #expect(session.requests.isEmpty) + } + + // MARK: - Conditional Lookup + + @Test("Sends The Entity Tag Quoted", arguments: [ + "9cf1", + #""9cf1""#, + #"W/"9cf1""#, + ]) + func sendsQuotedEntityTag(_ entityTag: String) async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + _ = try await makeServer(session: session).note(7, ifChangedFrom: entityTag) + + let request = try #require(session.requests.first) + let url = try #require(request.url) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + // The server only answers with not modified when the header repeats its strong tag quoted and exactly, so every shape a caller may hold is normalized to that. + #expect(request.value(forHTTPHeaderField: "If-None-Match") == #""9cf1""#) + #expect(request.httpMethod == "GET") + #expect(components.path == "/index.php/apps/notes/api/v1/notes/7") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + } + + @Test("A Changed Note Is Returned In Full") + func changedNote() async throws { + let session = MockRequesting(string: payload, headerFields: supportedHeaders) + let note = try await makeServer(session: session).note(7, ifChangedFrom: "outdated") + + // A tag which no longer matches makes the server answer as it would answer an unconditional lookup. + #expect(note?.id == 7) + #expect(note?.entityTag == "9cf1") + } + + @Test("Not Modified Is Reported As Nil") + func notModified() async throws { + // The server answers a matching tag with an empty body and still sends its version header, because the notes app adds it before the server turns the status into not modified. + let session = MockRequesting(string: "", statusCode: 304, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + let note = try await makeServer(session: session).note(7, ifChangedFrom: "9cf1") + + #expect(note == nil) + #expect(session.requests.count == 1) + } + + @Test("Polling With The Copy's Tag Returns Nil Until The Note Changes") + func pollingUntilChanged() async throws { + let changed = LockedValue(false) + let payload = payload + + // The responder plays the server: it answers not modified while the request repeats the current tag, and the note in full once the note changed. + let session = MockRequesting { request in + let currentTag = changed.get() ? "a1b2" : "9cf1" + + if request.value(forHTTPHeaderField: "If-None-Match") == "\"\(currentTag)\"" { + return (Data(), 304, ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + } + + let body = payload.replacingOccurrences(of: "9cf1", with: currentTag) + return (Data(body.utf8), 200, ["X-Notes-API-Versions": "0.2, 1.3, 1.4", "ETag": "\"\(currentTag)\""]) + } + + let server = makeServer(session: session) + let copy = try await server.note(7) + + #expect(try await server.note(7, ifChangedFrom: copy.entityTag) == nil) + #expect(try await server.note(7, ifChangedFrom: copy.entityTag) == nil) + + changed.set(true) + + let refreshed = try #require(try await server.note(7, ifChangedFrom: copy.entityTag)) + #expect(refreshed.entityTag == "a1b2") + #expect(try await server.note(7, ifChangedFrom: refreshed.entityTag) == nil) + } + + @Test("A Missing Note Is Not Found Whatever The Tag") + func conditionalMissingNote() async throws { + let session = MockRequesting(string: #"{"errorType":"OCA\\Notes\\Service\\NoteDoesNotExistException"}"#, statusCode: 404, headerFields: ["X-Notes-API-Versions": "0.2, 1.3, 1.4"]) + + // The server looks the note up before it compares tags, so a deleted note is never hidden behind nil. + await #expect(throws: RainmakerError.notFound) { + _ = try await makeServer(session: session).note(7, ifChangedFrom: "9cf1") + } + } +} diff --git a/Tests/RainmakerTests/SuspendingRequesting.swift b/Tests/RainmakerTests/SuspendingRequesting.swift new file mode 100644 index 0000000..9881440 --- /dev/null +++ b/Tests/RainmakerTests/SuspendingRequesting.swift @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2026 Iva Horn +// SPDX-License-Identifier: MIT + +import Foundation +@testable import Rainmaker + +/// +/// A ``Requesting`` test double which answers a given number of requests through a ``MockRequesting`` and keeps every request after those in flight until the task which sent it is cancelled. +/// +/// Data requests and uploads count alike, while downloads are answered unless ``suspendsDownloads`` says to count and suspend them as well. +/// +/// A ``MockRequesting`` answers synchronously, so it cannot prove that cancelling a task cancels the request that task is waiting for. This double can: a suspended request ends as a `URLSession` data task does when its task is cancelled, by throwing `URLError.cancelled`, and a request whose task is never cancelled never ends. +/// +final class SuspendingRequesting: Requesting, @unchecked Sendable { + /// + /// Answers the requests before the suspended ones and captures them in its ``MockRequesting/requests``. + /// + let answering: MockRequesting + + /// + /// The number of requests answered through ``answering`` before every further request is suspended. + /// + private let answeredCount: Int + + /// + /// Whether downloads are counted and suspended like data requests and uploads rather than always answered, which a test of cancelling ``Server/downloadAttachment(at:ofNote:to:force:)`` needs. + /// + private let suspendsDownloads: Bool + + /// + /// The number of requests received so far and the number of them currently suspended, which tests wait on through ``eventually(within:_:)``. + /// + private let counts = LockedValue((received: 0, suspended: 0)) + + /// + /// The number of requests currently suspended, waiting for their task to be cancelled. + /// + var suspendedCount: Int { + counts.get().suspended + } + + /// + /// The number of requests received so far, the suspended ones included. + /// + var receivedCount: Int { + counts.get().received + } + + /// + /// Create a double answering the given number of requests before suspending every further one. + /// + /// - Parameters: + /// - answeredCount: The number of requests answered through `answering`. + /// - answering: The mock which answers those requests. + /// - suspendsDownloads: Whether downloads count and are suspended like the other requests. Defaults to `false`. + /// + init(answering answeredCount: Int, through answering: MockRequesting, suspendsDownloads: Bool = false) { + self.answeredCount = answeredCount + self.answering = answering + self.suspendsDownloads = suspendsDownloads + } + + /// + /// The local files the uploads received so far were to be sent from, the suspended ones included, which a test checks to have been removed once an upload was cancelled. + /// + private let receivedUploadSources = LockedValue([URL]()) + + /// + /// The local files the uploads received so far were to be sent from, in order, the suspended ones included. + /// + var uploadSources: [URL] { + receivedUploadSources.get() + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard receive() else { + return try await answering.data(for: request) + } + + try await suspend() + } + + func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + guard suspendsDownloads, receive() else { + return try await answering.download(for: request, delegate: delegate) + } + + try await suspend() + } + + func upload(for request: URLRequest, fromFile fileURL: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + receivedUploadSources.withValue { $0.append(fileURL) } + + guard receive() else { + return try await answering.upload(for: request, fromFile: fileURL, delegate: delegate) + } + + try await suspend() + } + + /// + /// Count a received request and tell whether it is one to suspend rather than to answer. + /// + private func receive() -> Bool { + let index = counts.withValue { counts in + counts.received += 1 + return counts.received + } + + return index > answeredCount + } + + /// + /// Keep the calling request in flight until its task is cancelled, then end it as a `URLSession` task ends, by throwing `URLError.cancelled`. + /// + private func suspend() async throws -> Never { + counts.withValue { $0.suspended += 1 } + + defer { + counts.withValue { $0.suspended -= 1 } + } + + // An hour stands in for a request which never completes on its own, while cancelling the task ends the sleep right away. + do { + try await Task.sleep(nanoseconds: 3_600_000_000_000) + } catch { + throw URLError(.cancelled) + } + + throw URLError(.timedOut) + } +} diff --git a/Tests/RainmakerTests/URLRecordingSession.swift b/Tests/RainmakerTests/URLRecordingSession.swift index d2682bd..1582e40 100644 --- a/Tests/RainmakerTests/URLRecordingSession.swift +++ b/Tests/RainmakerTests/URLRecordingSession.swift @@ -86,8 +86,14 @@ public actor URLRecordingSession: Requesting { let (data, urlResponse) = try await session.upload(for: request, fromFile: fileURL, delegate: delegate) let (method, url, response) = try Self.components(of: request, urlResponse) - // The response body of an upload carries no payload relevant to the client, so only the headers are recorded, matching what ``URLTestSession`` replays. - try record(method: method, url: url, response: response, body: nil, bodyExtension: nil) + // Most uploads, such as those over WebDAV, are answered without a body, and their fixtures keep having none. A body which is not empty carries a payload the client reads, such as the path of an attachment of a note, so it is recorded under the extension the request's `Accept` header maps to, which is where ``URLTestSession`` replays it from. + if data.isEmpty == false, let acceptedType = request.allHTTPHeaderFields?["Accept"] { + let bodyExtension = try FixtureLocator.bodyExtension(forAcceptHeader: acceptedType) + let body = canonicalizer.canonicalizedBody(data, pathExtension: bodyExtension, requestURL: url) + try record(method: method, url: url, response: response, body: body, bodyExtension: bodyExtension) + } else { + try record(method: method, url: url, response: response, body: nil, bodyExtension: nil) + } return (data, urlResponse) } @@ -118,7 +124,7 @@ public actor URLRecordingSession: Requesting { let requestDirectory = locator.requestDirectory(in: testDirectory, method: method, url: url) try FileManager.default.createDirectory(atPath: requestDirectory.percentEncodedPath, withIntermediateDirectories: true) - let headersData = canonicalizer.headersText(statusCode: response.statusCode, headerFields: response.allHeaderFields) + let headersData = canonicalizer.headersText(statusCode: response.statusCode, headerFields: response.allHeaderFields, requestURL: url) let headersPath = locator.headersFile(in: requestDirectory).percentEncodedPath guard FileManager.default.createFile(atPath: headersPath, contents: headersData) else { diff --git a/Tests/RainmakerTests/URLTestSession.swift b/Tests/RainmakerTests/URLTestSession.swift index 66fc6b0..1d4342e 100644 --- a/Tests/RainmakerTests/URLTestSession.swift +++ b/Tests/RainmakerTests/URLTestSession.swift @@ -165,8 +165,20 @@ public actor URLTestSession: Requesting { throw URLTestSessionError.missingValue } - // The server response body to an upload carries no payload relevant to the client, so only the status from the headers file is replayed. - return (Data(), httpResponse) + // Most uploads, such as those over WebDAV, are answered without a body, so their fixtures have none and an empty body is replayed. An upload which is answered with a payload, such as an attachment of a note, has its body recorded under the extension its `Accept` header maps to, see ``URLRecordingSession/upload(for:fromFile:delegate:)``. + guard let acceptedType = request.allHTTPHeaderFields?["Accept"], let bodyFileExtension = try? FixtureLocator.bodyExtension(forAcceptHeader: acceptedType) else { + return (Data(), httpResponse) + } + + let bodyFile = locator.bodyFile(in: requestResources, pathExtension: bodyFileExtension) + + guard FileManager.default.fileExists(atPath: bodyFile.percentEncodedPath) else { + return (Data(), httpResponse) + } + + logger.debug("Assuming body file: \(bodyFile.percentEncodedPath)") + + return try (readDataFromPercentEncodedPath(at: bodyFile), httpResponse) } ///