|
| 1 | +import Foundation |
| 2 | +import Testing |
| 3 | + |
| 4 | +@testable import WordPress |
| 5 | +@testable import WordPressData |
| 6 | + |
| 7 | +@Suite("App Intent identifier parsing") |
| 8 | +struct AppIntentIdentifierParsingTests { |
| 9 | + @Test("a post identifier with a numeric domain parses") |
| 10 | + func postIdentifierWithSiteIDParses() { |
| 11 | + let identifier = AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|42") |
| 12 | + |
| 13 | + #expect(identifier?.domain == "111") |
| 14 | + #expect(identifier?.postID == 42) |
| 15 | + } |
| 16 | + |
| 17 | + @Test("a post identifier with an xmlrpc domain parses") |
| 18 | + func postIdentifierWithXMLRPCDomainParses() { |
| 19 | + let identifier = AbstractPost.AppIntentIdentifier( |
| 20 | + identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7" |
| 21 | + ) |
| 22 | + |
| 23 | + #expect(identifier?.domain == "https://example.com/xmlrpc.php") |
| 24 | + #expect(identifier?.postID == 7) |
| 25 | + } |
| 26 | + |
| 27 | + @Test("invalid post identifiers do not parse") |
| 28 | + func invalidPostIdentifiersDoNotParse() { |
| 29 | + #expect(AbstractPost.AppIntentIdentifier(identifier: "readerPost|~~~|111|~~~|42") == nil) |
| 30 | + #expect(AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|not-a-number") == nil) |
| 31 | + #expect(AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111") == nil) |
| 32 | + #expect(AbstractPost.AppIntentIdentifier(identifier: "garbage") == nil) |
| 33 | + } |
| 34 | + |
| 35 | + @Test("a reader post identifier parses, including IDs beyond Int32") |
| 36 | + func readerPostIdentifierParses() { |
| 37 | + let identifier = ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|3000000000|~~~|10000000000") |
| 38 | + |
| 39 | + #expect(identifier?.siteID == 3_000_000_000) |
| 40 | + #expect(identifier?.postID == 10_000_000_000) |
| 41 | + } |
| 42 | + |
| 43 | + @Test("invalid reader post identifiers do not parse") |
| 44 | + func invalidReaderPostIdentifiersDoNotParse() { |
| 45 | + #expect(ReaderPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|42") == nil) |
| 46 | + #expect(ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|example.com|~~~|42") == nil) |
| 47 | + #expect(ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|111|~~~|not-a-number") == nil) |
| 48 | + #expect(ReaderPost.AppIntentIdentifier(identifier: "garbage") == nil) |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +@MainActor |
| 53 | +@Suite("Post app intent resolution") |
| 54 | +struct PostAppIntentResolutionTests { |
| 55 | + @Test("a WP.com post identifier resolves the matching post") |
| 56 | + func dotComPostResolves() { |
| 57 | + let context = ContextManager.forTesting().mainContext |
| 58 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 59 | + let post = PostBuilder(context, blog: blog).published().build() |
| 60 | + post.postID = 42 |
| 61 | + |
| 62 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == post) |
| 63 | + } |
| 64 | + |
| 65 | + @Test("a self-hosted post identifier resolves via the xmlrpc domain") |
| 66 | + func selfHostedPostResolves() { |
| 67 | + let context = ContextManager.forTesting().mainContext |
| 68 | + let blog = BlogBuilder(context, dotComID: nil).build() |
| 69 | + blog.xmlrpc = "https://example.com/xmlrpc.php" |
| 70 | + let post = PostBuilder(context, blog: blog).published().build() |
| 71 | + post.postID = 7 |
| 72 | + |
| 73 | + #expect( |
| 74 | + AbstractPost.forAppIntent(identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7", in: context) |
| 75 | + == post |
| 76 | + ) |
| 77 | + } |
| 78 | + |
| 79 | + @Test("a page identifier resolves the matching page") |
| 80 | + func pageResolves() { |
| 81 | + let context = ContextManager.forTesting().mainContext |
| 82 | + let blog = BlogBuilder(context).with(dotComID: 333).build() |
| 83 | + let page = PageBuilder(context).build() |
| 84 | + page.blog = blog |
| 85 | + page.postID = 9 |
| 86 | + |
| 87 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|333|~~~|9", in: context) == page) |
| 88 | + } |
| 89 | + |
| 90 | + @Test("a trashed post identifier resolves nothing") |
| 91 | + func trashedPostResolvesNil() { |
| 92 | + let context = ContextManager.forTesting().mainContext |
| 93 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 94 | + let post = PostBuilder(context, blog: blog).trashed().build() |
| 95 | + post.postID = 42 |
| 96 | + |
| 97 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil) |
| 98 | + } |
| 99 | + |
| 100 | + @Test("an identifier for an unknown post resolves nothing") |
| 101 | + func unknownPostResolvesNil() { |
| 102 | + let context = ContextManager.forTesting().mainContext |
| 103 | + BlogBuilder(context).with(dotComID: 111).build() |
| 104 | + |
| 105 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil) |
| 106 | + } |
| 107 | + |
| 108 | + @Test("an identifier for an unknown site resolves nothing") |
| 109 | + func unknownSiteResolvesNil() { |
| 110 | + let context = ContextManager.forTesting().mainContext |
| 111 | + let post = PostBuilder(context, blog: BlogBuilder(context).with(dotComID: 111).build()).build() |
| 112 | + post.postID = 42 |
| 113 | + |
| 114 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|999|~~~|42", in: context) == nil) |
| 115 | + } |
| 116 | + |
| 117 | + @Test("a malformed identifier resolves nothing") |
| 118 | + func malformedIdentifierResolvesNil() { |
| 119 | + let context = ContextManager.forTesting().mainContext |
| 120 | + |
| 121 | + #expect(AbstractPost.forAppIntent(identifier: "garbage", in: context) == nil) |
| 122 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111", in: context) == nil) |
| 123 | + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|not-a-number", in: context) == nil) |
| 124 | + } |
| 125 | + |
| 126 | + @Test("a reader post identifier passed to the post resolver resolves nothing") |
| 127 | + func wrongItemTypeResolvesNil() { |
| 128 | + let context = ContextManager.forTesting().mainContext |
| 129 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 130 | + let post = PostBuilder(context, blog: blog).build() |
| 131 | + post.postID = 42 |
| 132 | + |
| 133 | + #expect(AbstractPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|42", in: context) == nil) |
| 134 | + } |
| 135 | + |
| 136 | + @Test("a reader post identifier resolves the matching reader post") |
| 137 | + func readerPostResolves() { |
| 138 | + let context = ContextManager.forTesting().mainContext |
| 139 | + let post = ReaderPostBuilder(context).build() |
| 140 | + post.postID = 42 |
| 141 | + post.siteID = 111 |
| 142 | + |
| 143 | + #expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|42", in: context) == post) |
| 144 | + } |
| 145 | + |
| 146 | + @Test("a reader post identifier with IDs beyond Int32 resolves the matching reader post") |
| 147 | + func readerPostWithLargeIDsResolves() { |
| 148 | + let context = ContextManager.forTesting().mainContext |
| 149 | + let post = ReaderPostBuilder(context).build() |
| 150 | + post.postID = NSNumber(value: 10_000_000_000) |
| 151 | + post.siteID = NSNumber(value: 3_000_000_000) |
| 152 | + |
| 153 | + #expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|3000000000|~~~|10000000000", in: context) == post) |
| 154 | + } |
| 155 | + |
| 156 | + @Test("an identifier for an unknown reader post resolves nothing") |
| 157 | + func unknownReaderPostResolvesNil() { |
| 158 | + let context = ContextManager.forTesting().mainContext |
| 159 | + let post = ReaderPostBuilder(context).build() |
| 160 | + post.postID = 42 |
| 161 | + post.siteID = 111 |
| 162 | + |
| 163 | + #expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|43", in: context) == nil) |
| 164 | + } |
| 165 | + |
| 166 | + @Test("a post identifier passed to the reader post resolver resolves nothing") |
| 167 | + func wrongItemTypeForReaderPostResolvesNil() { |
| 168 | + let context = ContextManager.forTesting().mainContext |
| 169 | + let post = ReaderPostBuilder(context).build() |
| 170 | + post.postID = 42 |
| 171 | + post.siteID = 111 |
| 172 | + |
| 173 | + #expect(ReaderPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil) |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +@MainActor |
| 178 | +@Suite("Post app intent search") |
| 179 | +struct PostAppIntentSearchTests { |
| 180 | + @Test("matches posts and pages by title, case-insensitively") |
| 181 | + func matchesByTitle() { |
| 182 | + let context = ContextManager.forTesting().mainContext |
| 183 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 184 | + let post = PostBuilder(context, blog: blog).published().with(title: "Hello World").build() |
| 185 | + post.postID = 1 |
| 186 | + let page = PageBuilder(context).build() |
| 187 | + page.blog = blog |
| 188 | + page.postTitle = "world tour" |
| 189 | + page.postID = 2 |
| 190 | + let other = PostBuilder(context, blog: blog).published().with(title: "Something else").build() |
| 191 | + other.postID = 3 |
| 192 | + |
| 193 | + let results = AbstractPost.searchForAppIntent(matching: "world", in: context) |
| 194 | + |
| 195 | + #expect(Set(results) == Set([post, page])) |
| 196 | + } |
| 197 | + |
| 198 | + @Test("excludes trashed posts, local-only posts, and revisions") |
| 199 | + func excludesUnsearchablePosts() { |
| 200 | + let context = ContextManager.forTesting().mainContext |
| 201 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 202 | + let match = PostBuilder(context, blog: blog).published().with(title: "Match A").build() |
| 203 | + match.postID = 1 |
| 204 | + _ = match.createRevision() |
| 205 | + let trashed = PostBuilder(context, blog: blog).trashed().with(title: "Match B").build() |
| 206 | + trashed.postID = 2 |
| 207 | + PostBuilder(context, blog: blog).drafted().with(title: "Match C, local only").build() |
| 208 | + |
| 209 | + let results = AbstractPost.searchForAppIntent(matching: "match", in: context) |
| 210 | + |
| 211 | + #expect(results == [match]) |
| 212 | + } |
| 213 | + |
| 214 | + @Test("an empty query returns recent posts, most recently modified first") |
| 215 | + func emptyQueryReturnsRecentPosts() { |
| 216 | + let context = ContextManager.forTesting().mainContext |
| 217 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 218 | + let older = PostBuilder(context, blog: blog).published().with(title: "Older") |
| 219 | + .with(dateModified: Date(timeIntervalSince1970: 1000)).build() |
| 220 | + older.postID = 1 |
| 221 | + let newer = PostBuilder(context, blog: blog).published().with(title: "Newer") |
| 222 | + .with(dateModified: Date(timeIntervalSince1970: 2000)).build() |
| 223 | + newer.postID = 2 |
| 224 | + |
| 225 | + let results = AbstractPost.searchForAppIntent(matching: "", in: context) |
| 226 | + |
| 227 | + #expect(results == [newer, older]) |
| 228 | + } |
| 229 | + |
| 230 | + @Test("caps the number of results") |
| 231 | + func capsResults() { |
| 232 | + let context = ContextManager.forTesting().mainContext |
| 233 | + let blog = BlogBuilder(context).with(dotComID: 111).build() |
| 234 | + for index in 1...3 { |
| 235 | + let post = PostBuilder(context, blog: blog).published().with(title: "Post \(index)").build() |
| 236 | + post.postID = NSNumber(value: index) |
| 237 | + } |
| 238 | + |
| 239 | + let results = AbstractPost.searchForAppIntent(matching: "", limit: 2, in: context) |
| 240 | + |
| 241 | + #expect(results.count == 2) |
| 242 | + } |
| 243 | + |
| 244 | + @Test("matches reader posts by title") |
| 245 | + func matchesReaderPostsByTitle() { |
| 246 | + let context = ContextManager.forTesting().mainContext |
| 247 | + let match = ReaderPostBuilder(context).build() |
| 248 | + match.postTitle = "Hello World" |
| 249 | + match.postID = 1 |
| 250 | + match.siteID = 9 |
| 251 | + let other = ReaderPostBuilder(context).build() |
| 252 | + other.postTitle = "Something else" |
| 253 | + other.postID = 2 |
| 254 | + other.siteID = 9 |
| 255 | + |
| 256 | + let results = ReaderPost.searchForAppIntent(matching: "world", in: context) |
| 257 | + |
| 258 | + #expect(results == [match]) |
| 259 | + } |
| 260 | + |
| 261 | + @Test("an empty query returns recent reader posts, newest first") |
| 262 | + func emptyQueryReturnsRecentReaderPosts() { |
| 263 | + let context = ContextManager.forTesting().mainContext |
| 264 | + let older = ReaderPostBuilder(context).build() |
| 265 | + older.postTitle = "Older" |
| 266 | + older.postID = 1 |
| 267 | + older.siteID = 9 |
| 268 | + older.sortDate = Date(timeIntervalSince1970: 1000) |
| 269 | + let newer = ReaderPostBuilder(context).build() |
| 270 | + newer.postTitle = "Newer" |
| 271 | + newer.postID = 2 |
| 272 | + newer.siteID = 9 |
| 273 | + newer.sortDate = Date(timeIntervalSince1970: 2000) |
| 274 | + |
| 275 | + let results = ReaderPost.searchForAppIntent(matching: "", in: context) |
| 276 | + |
| 277 | + #expect(results == [newer, older]) |
| 278 | + } |
| 279 | + |
| 280 | + @Test("reader posts cached under multiple topics are returned once") |
| 281 | + func deduplicatesReaderPosts() { |
| 282 | + let context = ContextManager.forTesting().mainContext |
| 283 | + let newest = ReaderPostBuilder(context).build() |
| 284 | + newest.postTitle = "Duplicate" |
| 285 | + newest.postID = 1 |
| 286 | + newest.siteID = 9 |
| 287 | + newest.sortDate = Date(timeIntervalSince1970: 3000) |
| 288 | + let duplicate = ReaderPostBuilder(context).build() |
| 289 | + duplicate.postTitle = "Duplicate" |
| 290 | + duplicate.postID = 1 |
| 291 | + duplicate.siteID = 9 |
| 292 | + duplicate.sortDate = Date(timeIntervalSince1970: 2000) |
| 293 | + let other = ReaderPostBuilder(context).build() |
| 294 | + other.postTitle = "Other" |
| 295 | + other.postID = 2 |
| 296 | + other.siteID = 9 |
| 297 | + other.sortDate = Date(timeIntervalSince1970: 1000) |
| 298 | + |
| 299 | + let results = ReaderPost.searchForAppIntent(matching: "", in: context) |
| 300 | + |
| 301 | + #expect(results == [newest, other]) |
| 302 | + } |
| 303 | + |
| 304 | + @Test("excludes reader posts that cannot be identified") |
| 305 | + func excludesUnidentifiableReaderPosts() { |
| 306 | + let context = ContextManager.forTesting().mainContext |
| 307 | + let post = ReaderPostBuilder(context).build() |
| 308 | + post.postTitle = "Hello World" |
| 309 | + post.postID = 1 |
| 310 | + |
| 311 | + #expect(ReaderPost.searchForAppIntent(matching: "world", in: context) == []) |
| 312 | + } |
| 313 | +} |
0 commit comments