diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 6e18871a8873..46ec527531b7 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -3,6 +3,22 @@ This file documents changes in the data model. Please explain any changes to the data model as well as any custom migrations. +## WordPress 140 + +@dvdchr 2022-05-13 + +- Created a new entity `BloggingPrompt` with: + - `promptID` (required, default `0`, `Int 32`) + - `siteID` (required, default `0`, `Int 32`) + - `text` (required, default empty string, `String`) + - `title` (required, default empty string, `String`) + - `content` (required, default empty string, `String`) + - `attribution` (required, default empty string, `String`) + - `date` (optional, no default, `Date`) + - `answered` (required, default `NO`, `Boolean`) + - `answerCount` (required, default `0`, `Int 32`) + - `displayAvatarURLs` (optional, no default, `Transformable` with type `[URL]`) + ## WordPress 138 @dvdchr 2022-03-07 diff --git a/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift new file mode 100644 index 000000000000..6f59f71b39c2 --- /dev/null +++ b/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift @@ -0,0 +1,41 @@ +import Foundation +import CoreData +import WordPressKit + +public class BloggingPrompt: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: Self.classNameWithoutNamespaces()) + } + + @nonobjc public class func newObject(in context: NSManagedObjectContext) -> BloggingPrompt? { + return NSEntityDescription.insertNewObject(forEntityName: Self.classNameWithoutNamespaces(), into: context) as? BloggingPrompt + } + + public override func awakeFromInsert() { + self.date = .init(timeIntervalSince1970: 0) + self.displayAvatarURLs = [] + } + + var promptAttribution: BloggingPromptsAttribution? { + BloggingPromptsAttribution(rawValue: attribution.lowercased()) + } + + /// Convenience method to map properties from `RemoteBloggingPrompt`. + /// + /// - Parameters: + /// - remotePrompt: The remote prompt model to convert + /// - siteID: The ID of the site that the prompt is intended for + func configure(with remotePrompt: RemoteBloggingPrompt, for siteID: Int32) { + self.promptID = Int32(remotePrompt.promptID) + self.siteID = siteID + self.text = remotePrompt.text + self.title = remotePrompt.title + self.content = remotePrompt.content + self.attribution = remotePrompt.attribution + self.date = remotePrompt.date + self.answered = remotePrompt.answered + self.answerCount = Int32(remotePrompt.answeredUsersCount) + self.displayAvatarURLs = remotePrompt.answeredUserAvatarURLs + } +} diff --git a/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift new file mode 100644 index 000000000000..a40e60d39968 --- /dev/null +++ b/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift @@ -0,0 +1,34 @@ +import Foundation +import CoreData + +extension BloggingPrompt { + /// The unique ID for the prompt, received from the server. + @NSManaged public var promptID: Int32 + + /// The site ID for the prompt. + @NSManaged public var siteID: Int32 + + /// The prompt content to be displayed at entry points. + @NSManaged public var text: String + + /// Template title for the draft post. + @NSManaged public var title: String + + /// Template content for the draft post. + @NSManaged public var content: String + + /// The attribution source for the prompt. + @NSManaged public var attribution: String + + /// The prompt date. Time information should be ignored. + @NSManaged public var date: Date + + /// Whether the current user has answered the prompt in `siteID`. + @NSManaged public var answered: Bool + + /// The number of users that has answered the prompt. + @NSManaged public var answerCount: Int32 + + /// Contains avatar URLs of some users that have answered the prompt. + @NSManaged public var displayAvatarURLs: [URL] +} diff --git a/WordPress/Classes/Services/BloggingPromptsService.swift b/WordPress/Classes/Services/BloggingPromptsService.swift index 6ce8e637401e..d839d7da51d8 100644 --- a/WordPress/Classes/Services/BloggingPromptsService.swift +++ b/WordPress/Classes/Services/BloggingPromptsService.swift @@ -2,13 +2,25 @@ import CoreData import WordPressKit class BloggingPromptsService { - private let context: NSManagedObjectContext + private let contextManager: CoreDataStack private let siteID: NSNumber private let remote: BloggingPromptsServiceRemote private let calendar: Calendar = .autoupdatingCurrent - private var defaultDate: Date { - calendar.date(byAdding: .day, value: -10, to: Date()) ?? Date() + /// A UTC date formatter that ignores time information. + private static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = .init(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + + return formatter + }() + + /// Convenience computed variable that returns today's prompt from local store. + /// + var localTodaysPrompt: BloggingPrompt? { + loadPrompts(from: Date(), number: 1).first } /// Fetches a number of blogging prompts starting from the specified date. @@ -23,12 +35,18 @@ class BloggingPromptsService { number: Int = 24, success: @escaping ([BloggingPrompt]) -> Void, failure: @escaping (Error?) -> Void) { - let fromDate = date ?? defaultDate + let fromDate = date ?? defaultStartDate remote.fetchPrompts(for: siteID, number: number, fromDate: fromDate) { result in switch result { case .success(let remotePrompts): - // TODO: Upsert into CoreData once the CoreData model is available. - success(remotePrompts.map { BloggingPrompt(with: $0) }) + self.upsert(with: remotePrompts) { innerResult in + if case .failure(let error) = innerResult { + failure(error) + return + } + + success(self.loadPrompts(from: fromDate, number: number)) + } case .failure(let error): failure(error) } @@ -59,45 +77,114 @@ class BloggingPromptsService { fetchPrompts(from: fromDate, number: 11, success: success, failure: failure) } - required init?(context: NSManagedObjectContext = ContextManager.shared.mainContext, + required init?(contextManager: CoreDataStack = ContextManager.shared, remote: BloggingPromptsServiceRemote? = nil, blog: Blog? = nil) { - guard let account = AccountService(managedObjectContext: context).defaultWordPressComAccount(), + guard let account = AccountService(managedObjectContext: contextManager.mainContext).defaultWordPressComAccount(), let siteID = blog?.dotComID ?? account.primaryBlogID else { return nil } - self.context = context + self.contextManager = contextManager self.siteID = siteID self.remote = remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api) } } -// MARK: - Temporary model object - -/// TODO: This is a temporary model to be replaced with Core Data model once the fields have all been finalized. -struct BloggingPrompt { - let promptID: Int - let text: String - let title: String // for post title - let content: String // for post content - let date: Date - let answered: Bool - let answerCount: Int - let displayAvatarURLs: [URL] - let attribution: String -} +// MARK: - Private Helpers + +private extension BloggingPromptsService { + + var defaultStartDate: Date { + calendar.date(byAdding: .day, value: -10, to: Date()) ?? Date() + } + + /// Converts the given date to UTC and ignores the time information. + /// Example: Given `2022-05-01 03:00:00 UTC-5`, this should return `2022-05-01 00:00:00 UTC`. + /// + /// - Parameter date: The date to convert. + /// - Returns: The UTC date without the time information. + func utcDateIgnoringTime(from date: Date) -> Date? { + let utcDateString = Self.dateFormatter.string(from: date) + return Self.dateFormatter.date(from: utcDateString) + } + + /// Loads local prompts based on the given parameters. + /// + /// - Parameters: + /// - date: When specified, only prompts from the specified date will be returned. + /// - number: The amount of prompts to return. Defaults to 24 when unspecified. + /// - Returns: An array of `BloggingPrompt` objects sorted descending by date. + func loadPrompts(from date: Date, number: Int) -> [BloggingPrompt] { + guard let utcDate = utcDateIgnoringTime(from: date) else { + DDLogError("Error converting date to UTC: \(date)") + return [] + } + + let fetchRequest = BloggingPrompt.fetchRequest() + fetchRequest.predicate = .init(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) >= %@", siteID, utcDate as NSDate) + fetchRequest.fetchLimit = number + fetchRequest.sortDescriptors = [.init(key: #keyPath(BloggingPrompt.date), ascending: false)] -extension BloggingPrompt { - init(with remotePrompt: RemoteBloggingPrompt) { - promptID = remotePrompt.promptID - text = remotePrompt.text - title = remotePrompt.title - content = remotePrompt.content - date = remotePrompt.date - answered = remotePrompt.answered - answerCount = remotePrompt.answeredUsersCount - displayAvatarURLs = remotePrompt.answeredUserAvatarURLs - attribution = remotePrompt.attribution + return (try? self.contextManager.mainContext.fetch(fetchRequest)) ?? [] + } + + /// Find and update existing prompts, or insert new ones if they don't exist. + /// + /// - Parameters: + /// - remotePrompts: An array containing prompts obtained from remote. + /// - completion: Closure to be called after the process completes. Returns an array of prompts when successful. + func upsert(with remotePrompts: [RemoteBloggingPrompt], completion: @escaping (Result) -> Void) { + if remotePrompts.isEmpty { + completion(.success(())) + return + } + + let remoteIDs = Set(remotePrompts.map { Int32($0.promptID) }) + let remotePromptsDictionary = remotePrompts.reduce(into: [Int32: RemoteBloggingPrompt]()) { partialResult, remotePrompt in + partialResult[Int32(remotePrompt.promptID)] = remotePrompt + } + + let predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) IN %@", siteID, remoteIDs) + let fetchRequest = BloggingPrompt.fetchRequest() + fetchRequest.predicate = predicate + + let derivedContext = contextManager.newDerivedContext() + derivedContext.perform { + do { + // Update existing prompts + var foundExistingIDs = [Int32]() + let results = try derivedContext.fetch(fetchRequest) + results.forEach { prompt in + guard let remotePrompt = remotePromptsDictionary[prompt.promptID] else { + return + } + + foundExistingIDs.append(prompt.promptID) + prompt.configure(with: remotePrompt, for: self.siteID.int32Value) + } + + // Insert new prompts + let newPromptIDs = remoteIDs.subtracting(foundExistingIDs) + newPromptIDs.forEach { newPromptID in + guard let remotePrompt = remotePromptsDictionary[newPromptID], + let newPrompt = BloggingPrompt.newObject(in: derivedContext) else { + return + } + newPrompt.configure(with: remotePrompt, for: self.siteID.int32Value) + } + + self.contextManager.save(derivedContext) { + DispatchQueue.main.async { + completion(.success(())) + } + } + + } catch let error { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift index 985bea16420c..c86243ebacc1 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift @@ -116,7 +116,7 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { return Constants.exampleAnswerCount } - return prompt?.answerCount ?? 0 + return Int(prompt?.answerCount ?? 0) }() private var answerInfoText: String { @@ -352,8 +352,7 @@ private extension DashboardPromptsCardCell { promptLabel.text = forExampleDisplay ? Strings.examplePrompt : prompt?.text.stringByDecodingXMLCharacters().trim() containerStackView.addArrangedSubview(promptTitleView) - if let promptAttribution = prompt?.attribution.lowercased(), - let attribution = BloggingPromptsAttribution(rawValue: promptAttribution) { + if let attribution = prompt?.promptAttribution { attributionIcon.image = attribution.iconImage attributionSourceLabel.attributedText = attribution.attributedText containerStackView.addArrangedSubview(attributionStackView) @@ -391,7 +390,7 @@ private extension DashboardPromptsCardCell { @objc func answerButtonTapped() { guard let blog = blog, - let prompt = prompt else { + let prompt = prompt else { return } diff --git a/WordPress/Classes/WordPress.xcdatamodeld/.xccurrentversion b/WordPress/Classes/WordPress.xcdatamodeld/.xccurrentversion index a6087e972746..69ab7147a303 100644 --- a/WordPress/Classes/WordPress.xcdatamodeld/.xccurrentversion +++ b/WordPress/Classes/WordPress.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - WordPress 139.xcdatamodel + WordPress 140.xcdatamodel diff --git a/WordPress/Classes/WordPress.xcdatamodeld/WordPress 140.xcdatamodel/contents b/WordPress/Classes/WordPress.xcdatamodeld/WordPress 140.xcdatamodel/contents new file mode 100644 index 000000000000..1592098f88b9 --- /dev/null +++ b/WordPress/Classes/WordPress.xcdatamodeld/WordPress 140.xcdatamodel/contents @@ -0,0 +1,1076 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 25526cffcb8a..04b01648af94 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -4584,6 +4584,11 @@ FAFF153D1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFF153C1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift */; }; FD21397F13128C5300099582 /* libiconv.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FD21397E13128C5300099582 /* libiconv.dylib */; }; FD3D6D2C1349F5D30061136A /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD3D6D2B1349F5D30061136A /* ImageIO.framework */; }; + FE003F5D282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */; }; + FE003F5E282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */; }; + FE003F5F282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */; }; + FE003F60282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */; }; + FE003F62282E73E6006F8D1D /* blogging-prompts-fetch-success.json in Resources */ = {isa = PBXBuildFile; fileRef = FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */; }; FE02F95F269DC14A00752A44 /* Comment+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE02F95E269DC14A00752A44 /* Comment+Interface.swift */; }; FE06AC8326C3BD0900B69DE4 /* ShareAppContentPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE06AC8226C3BD0900B69DE4 /* ShareAppContentPresenter.swift */; }; FE06AC8526C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE06AC8426C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift */; }; @@ -7872,6 +7877,10 @@ FD3D6D2B1349F5D30061136A /* ImageIO.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = ImageIO.framework; path = System/Library/Frameworks/ImageIO.framework; sourceTree = SDKROOT; }; FDCB9A89134B75B900E5C776 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; FDFB011916B1EA1C00F589A8 /* WordPress 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 10.xcdatamodel"; sourceTree = ""; }; + FE003F54282D48E4006F8D1D /* WordPress 140.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 140.xcdatamodel"; sourceTree = ""; }; + FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BloggingPrompt+CoreDataClass.swift"; sourceTree = ""; }; + FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BloggingPrompt+CoreDataProperties.swift"; sourceTree = ""; }; + FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blogging-prompts-fetch-success.json"; sourceTree = ""; }; FE02F95E269DC14A00752A44 /* Comment+Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comment+Interface.swift"; sourceTree = ""; }; FE06AC8226C3BD0900B69DE4 /* ShareAppContentPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppContentPresenter.swift; sourceTree = ""; }; FE06AC8426C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppTextActivityItemSource.swift; sourceTree = ""; }; @@ -9051,6 +9060,8 @@ 5D42A3D8175E7452005CFF05 /* BasePost.h */, 5D42A3D9175E7452005CFF05 /* BasePost.m */, E19B17AD1E5C6944007517C6 /* BasePost.swift */, + FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */, + FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */, 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */, 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */, E14932B4130427B300154804 /* Coordinate.h */, @@ -14431,6 +14442,7 @@ 855408871A6F106800DDBD79 /* app-review-prompt-notifications-disabled.json */, F127FFD724213B5600B9D41A /* atomic-get-authentication-cookie-success.json */, 93CD939219099BE70049096E /* authtoken.json */, + FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */, FEFC0F8D27313DCF001F7F1D /* comments-v2-success.json */, FEFC0F8F27315634001F7F1D /* empty-array.json */, 74585B9A1F0D591D00E7E667 /* domain-service-valid-domains.json */, @@ -16506,6 +16518,7 @@ B5AEEC7D1ACACFDA008BF2A4 /* notifications-replied-comment.json in Resources */, 8B2D4F5327ECE089009B085C /* dashboard-200-without-posts.json in Resources */, FEFC0F8E27313DD0001F7F1D /* comments-v2-success.json in Resources */, + FE003F62282E73E6006F8D1D /* blogging-prompts-fetch-success.json in Resources */, 46CFA7BF262745F70077BAD9 /* get_wp_v2_themes_twentytwentyone.json in Resources */, 465F89F7263B690C00F4C950 /* wp-block-editor-v1-settings-success-NotThemeJSON.json in Resources */, 3211055C250C027D0048446F /* valid-gif-header.gif in Resources */, @@ -18078,6 +18091,7 @@ 32E1BFDA24A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift in Sources */, FEC3B81726C2915A00A395C7 /* SingleButtonTableViewCell.swift in Sources */, 836498C828172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift in Sources */, + FE003F5E282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */, FF70A3221FD5840500BC270D /* PHAsset+Metadata.swift in Sources */, 5DA5BF4418E32DCF005F11F9 /* Theme.m in Sources */, D8A3A5B1206A49A100992576 /* StockPhotosMediaGroup.swift in Sources */, @@ -18355,6 +18369,7 @@ D8A3A5AF206A442800992576 /* StockPhotosDataSource.swift in Sources */, 8B24C4E3249A4C3E0005E8A5 /* OfflineReaderWebView.swift in Sources */, 931215E6267F5192008C3B69 /* ReferrerDetailsViewModel.swift in Sources */, + FE003F60282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */, 9A5C854822B3E42800BEE7A3 /* CountriesMapCell.swift in Sources */, B5B410B61B1772B000CFCF8D /* NavigationTitleView.swift in Sources */, DC772B0928201F5300664C02 /* ViewsVisitorsChartMarker.swift in Sources */, @@ -21083,6 +21098,7 @@ FABB24352602FC2C00C8785C /* ErrorStateViewController.swift in Sources */, FABB24362602FC2C00C8785C /* WP3DTouchShortcutCreator.swift in Sources */, FABB24372602FC2C00C8785C /* GIFPlaybackStrategy.swift in Sources */, + FE003F5F282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */, FABB24382602FC2C00C8785C /* ReaderCommentAction.swift in Sources */, FABB24392602FC2C00C8785C /* JetpackBackupStatusFailedViewController.swift in Sources */, FABB243A2602FC2C00C8785C /* CircularImageView.swift in Sources */, @@ -21139,6 +21155,7 @@ 8BE8545E27DBAFED00E4C69A /* BlogDashboardAB.swift in Sources */, FABB24632602FC2C00C8785C /* PushAuthenticationService.swift in Sources */, FABB24642602FC2C00C8785C /* AlertView.swift in Sources */, + FE003F5D282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */, FABB24652602FC2C00C8785C /* WPTabBarController.m in Sources */, FABB24662602FC2C00C8785C /* UIAlertControllerProxy.m in Sources */, FABB24672602FC2C00C8785C /* StatsBarChartView.swift in Sources */, @@ -26038,6 +26055,7 @@ E125443B12BF5A7200D87A0A /* WordPress.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + FE003F54282D48E4006F8D1D /* WordPress 140.xcdatamodel */, 17870A72281847D500D1C627 /* WordPress 139.xcdatamodel */, FE59DA9527D1FD0700624D26 /* WordPress 138.xcdatamodel */, FE97BC13274FCE7A00CF08F9 /* WordPress 137.xcdatamodel */, @@ -26178,7 +26196,7 @@ 8350E15911D28B4A00A7B073 /* WordPress.xcdatamodel */, E125443D12BF5A7200D87A0A /* WordPress 2.xcdatamodel */, ); - currentVersion = 17870A72281847D500D1C627 /* WordPress 139.xcdatamodel */; + currentVersion = FE003F54282D48E4006F8D1D /* WordPress 140.xcdatamodel */; name = WordPress.xcdatamodeld; path = Classes/WordPress.xcdatamodeld; sourceTree = ""; diff --git a/WordPress/WordPressTest/BloggingPromptsServiceTests.swift b/WordPress/WordPressTest/BloggingPromptsServiceTests.swift index fa811a97c985..d421f7a3c37b 100644 --- a/WordPress/WordPressTest/BloggingPromptsServiceTests.swift +++ b/WordPress/WordPressTest/BloggingPromptsServiceTests.swift @@ -1,13 +1,21 @@ import XCTest +import OHHTTPStubs @testable import WordPress final class BloggingPromptsServiceTests: XCTestCase { private let siteID = 1 private let timeout: TimeInterval = 2 - private var endpoint: String { - "sites/\(siteID)/blogging-prompts" - } + + private static let utcTimeZone = TimeZone(secondsFromGMT: 0)! + private static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = utcTimeZone + + return formatter + }() private var contextManager: ContextManagerMock! private var context: NSManagedObjectContext { @@ -25,10 +33,13 @@ final class BloggingPromptsServiceTests: XCTestCase { remote = BloggingPromptsServiceRemoteMock() blog = makeBlog() accountService = makeAccountService() - service = BloggingPromptsService(context: context, remote: remote, blog: blog) + service = BloggingPromptsService(contextManager: contextManager, remote: remote, blog: blog) } override func tearDown() { + ContextManager.overrideSharedInstance(nil) + HTTPStubs.removeAllStubs() + contextManager = nil remote = nil blog = nil @@ -40,12 +51,80 @@ final class BloggingPromptsServiceTests: XCTestCase { // MARK: - Tests func test_fetchPrompts_givenSuccessfulResult_callsSuccessBlock() { + // use actual remote object so the request can be intercepted by HTTPStubs. + service = BloggingPromptsService(contextManager: contextManager, blog: blog) + stubFetchPromptsResponse() + let expectation = expectation(description: "Fetch prompts should succeed") + service.fetchPrompts(from: .init(timeIntervalSince1970: 0)) { prompts in + XCTAssertEqual(prompts.count, 2) + + // Verify mappings for the first prompt + let firstPrompt = prompts.first! + XCTAssertEqual(firstPrompt.promptID, 239) + XCTAssertEqual(firstPrompt.text, "Was there a toy or thing you always wanted as a child, during the holidays or on your birthday, but never received? Tell us about it.") + XCTAssertEqual(firstPrompt.title, "Prompt number 1") + XCTAssertEqual(firstPrompt.content, "\n

Was there a toy or thing you always wanted as a child, during the holidays or on your birthday, but never received? Tell us about it.

(courtesy of plinky.com)
\n") + XCTAssertEqual(firstPrompt.attribution, "dayone") + + let firstDateComponents = Calendar.current.dateComponents(in: Self.utcTimeZone, from: firstPrompt.date) + XCTAssertEqual(firstDateComponents.year!, 2022) + XCTAssertEqual(firstDateComponents.month!, 5) + XCTAssertEqual(firstDateComponents.day!, 3) + + XCTAssertFalse(firstPrompt.answered) + XCTAssertEqual(firstPrompt.answerCount, 0) + XCTAssertTrue(firstPrompt.displayAvatarURLs.isEmpty) + + // Verify mappings for the second prompt + let secondPrompt = prompts.last! + XCTAssertEqual(secondPrompt.promptID, 248) + XCTAssertEqual(secondPrompt.text, "Tell us about a time when you felt out of place.") + XCTAssertEqual(secondPrompt.title, "Prompt number 10") + XCTAssertEqual(secondPrompt.content, "\n

Tell us about a time when you felt out of place.

(courtesy of plinky.com)
\n") + XCTAssertTrue(secondPrompt.attribution.isEmpty) + + let secondDateComponents = Calendar.current.dateComponents(in: Self.utcTimeZone, from: secondPrompt.date) + XCTAssertEqual(secondDateComponents.year!, 2021) + XCTAssertEqual(secondDateComponents.month!, 9) + XCTAssertEqual(secondDateComponents.day!, 12) + + XCTAssertTrue(secondPrompt.answered) + XCTAssertEqual(secondPrompt.answerCount, 1) + XCTAssertEqual(secondPrompt.displayAvatarURLs.count, 1) - service.fetchPrompts { _ in - // TODO: Add mapping tests once CoreData model is added. expectation.fulfill() - } failure: { _ in + + } failure: { error in + XCTFail("This closure shouldn't be called.") + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + } + + func test_fetchPrompts_shouldExcludePromptsOutsideGivenDate() { + // this should exclude the second prompt dated 2021-09-12. + let dateParam = Self.dateFormatter.date(from: "2022-01-01") + + // use actual remote object so the request can be intercepted by HTTPStubs. + service = BloggingPromptsService(contextManager: contextManager, blog: blog) + stubFetchPromptsResponse() + + let expectation = expectation(description: "Fetch prompts should succeed") + service.fetchPrompts(from: dateParam) { prompts in + XCTAssertEqual(prompts.count, 1) + + // Ensure that the date returned is more recent than the supplied date parameter. + let firstPrompt = prompts.first! + let firstDateComponents = Calendar.current.dateComponents(in: Self.utcTimeZone, from: firstPrompt.date) + XCTAssertEqual(firstDateComponents.year!, 2022) + XCTAssertEqual(firstDateComponents.month!, 5) + XCTAssertEqual(firstDateComponents.day!, 3) + + expectation.fulfill() + + } failure: { error in XCTFail("This closure shouldn't be called.") expectation.fulfill() } @@ -77,7 +156,7 @@ final class BloggingPromptsServiceTests: XCTestCase { XCTAssertNotNil(remote.passedDateParameter) let passedDate = remote.passedDateParameter! - let differenceInHours = Calendar.autoupdatingCurrent.dateComponents([.hour], from: passedDate, to: Date()).hour! + let differenceInHours = Calendar.current.dateComponents([.hour], from: passedDate, to: Date()).hour! XCTAssertEqual(differenceInHours, expectedDifferenceInHours) XCTAssertNotNil(remote.passedNumberParameter) @@ -105,7 +184,7 @@ final class BloggingPromptsServiceTests: XCTestCase { private extension BloggingPromptsServiceTests { func makeAccountService() -> AccountService { - let service = AccountService(managedObjectContext: context) + let service = AccountService(managedObjectContext: contextManager.mainContext) let account = service.createOrUpdateAccount(withUsername: "testuser", authToken: "authtoken") account.userID = NSNumber(value: 1) service.setDefaultWordPressComAccount(account) @@ -114,7 +193,14 @@ private extension BloggingPromptsServiceTests { } func makeBlog() -> Blog { - return BlogBuilder(context).isHostedAtWPcom().build() + return BlogBuilder(contextManager.mainContext).isHostedAtWPcom().build() + } + + func stubFetchPromptsResponse() { + stub(condition: isMethodGET()) { _ in + let stubPath = OHPathForFile("blogging-prompts-fetch-success.json", type(of: self)) + return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"]) + } } } @@ -123,6 +209,7 @@ class BloggingPromptsServiceRemoteMock: BloggingPromptsServiceRemote { var passedNumberParameter: Int? = nil var passedDateParameter: Date? = nil var shouldReturnSuccess: Bool = true + var promptsToReturn = [RemoteBloggingPrompt]() override func fetchPrompts(for siteID: NSNumber, number: Int? = nil, @@ -133,7 +220,7 @@ class BloggingPromptsServiceRemoteMock: BloggingPromptsServiceRemote { passedDateParameter = fromDate if shouldReturnSuccess { - completion(.success([])) + completion(.success(promptsToReturn)) } else { completion(.failure(Errors.failed)) } diff --git a/WordPress/WordPressTest/Test Data/blogging-prompts-fetch-success.json b/WordPress/WordPressTest/Test Data/blogging-prompts-fetch-success.json new file mode 100644 index 000000000000..f7a8d7ee646d --- /dev/null +++ b/WordPress/WordPressTest/Test Data/blogging-prompts-fetch-success.json @@ -0,0 +1,30 @@ +{ + "prompts": [ + { + "id": 239, + "text": "Was there a toy or thing you always wanted as a child, during the holidays or on your birthday, but never received? Tell us about it.", + "title": "Prompt number 1", + "content": "\n

Was there a toy or thing you always wanted as a child, during the holidays or on your birthday, but never received? Tell us about it.

(courtesy of plinky.com)
\n", + "attribution": "dayone", + "date": "2022-05-03", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 248, + "text": "Tell us about a time when you felt out of place.", + "title": "Prompt number 10", + "content": "\n

Tell us about a time when you felt out of place.

(courtesy of plinky.com)
\n", + "attribution": "", + "date": "2021-09-12", + "answered": true, + "answered_users_count": 1, + "answered_users_sample": [ + { + "avatar": "https://0.gravatar.com/avatar/example?s=96&d=identicon&r=G" + } + ] + } + ] +}