Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions Tests/KeystoneTests/Tests/Reader/ReaderSavedPostsExporterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
import XCTest
import WordPressData

@testable import WordPress

class ReaderSavedPostsExporterTests: CoreDataTestCase {

private let exporter = ReaderSavedPostsExporter()

// MARK: - Export

func testExportReturnsNilWhenNoSavedPosts() throws {
let result = try exporter.export(context: mainContext)
XCTAssertNil(result)
}

func testExportReturnsNilWhenPostsExistButNoneAreSaved() throws {
let post = makeReaderPost()
post.isSavedForLater = false
try mainContext.save()

let result = try exporter.export(context: mainContext)
XCTAssertNil(result)
}

func testExportCreatesJSONFileWithSavedPosts() throws {
let post = makeReaderPost()
post.postTitle = "Test Post"
post.permaLink = "https://example.com/test-post"
post.authorDisplayName = "Jane Doe"
post.blogName = "Example Blog"
post.blogURL = "https://example.com"
post.summary = "A short summary"
post.featuredImage = "https://example.com/image.jpg"
post.tags = "swift, ios"
post.siteID = 12345
post.postID = 67890
post.isExternal = false
post.isSavedForLater = true
post.sortDate = Date(timeIntervalSince1970: 1000000)
post.date_created_gmt = Date(timeIntervalSince1970: 1000000)
try mainContext.save()

let fileURL = try XCTUnwrap(exporter.export(context: mainContext))

let data = try Data(contentsOf: fileURL)
let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])

XCTAssertEqual(envelope["postCount"] as? Int, 1)
XCTAssertNotNil(envelope["exportDate"])

let posts = try XCTUnwrap(envelope["posts"] as? [[String: Any]])
XCTAssertEqual(posts.count, 1)

let exported = posts[0]
XCTAssertEqual(exported["title"] as? String, "Test Post")
XCTAssertEqual(exported["url"] as? String, "https://example.com/test-post")
XCTAssertEqual(exported["author"] as? String, "Jane Doe")
XCTAssertEqual(exported["siteName"] as? String, "Example Blog")
XCTAssertEqual(exported["siteURL"] as? String, "https://example.com")
XCTAssertEqual(exported["summary"] as? String, "A short summary")
XCTAssertEqual(exported["featuredImageURL"] as? String, "https://example.com/image.jpg")
XCTAssertEqual(exported["tags"] as? [String], ["swift", "ios"])
XCTAssertEqual((exported["siteID"] as? NSNumber)?.intValue, 12345)
XCTAssertEqual((exported["postID"] as? NSNumber)?.intValue, 67890)
XCTAssertEqual(exported["isFeed"] as? Bool, false)
}

func testExportOnlyIncludesSavedPosts() throws {
let saved = makeReaderPost()
saved.postTitle = "Saved"
saved.permaLink = "https://example.com/saved"
saved.isSavedForLater = true
saved.sortDate = Date()

let unsaved = makeReaderPost()
unsaved.postTitle = "Unsaved"
unsaved.permaLink = "https://example.com/unsaved"
unsaved.isSavedForLater = false
unsaved.sortDate = Date()

try mainContext.save()

let fileURL = try XCTUnwrap(exporter.export(context: mainContext))
let data = try Data(contentsOf: fileURL)
let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
let posts = try XCTUnwrap(envelope["posts"] as? [[String: Any]])

XCTAssertEqual(posts.count, 1)
XCTAssertEqual(posts[0]["title"] as? String, "Saved")
}

func testExportOmitsEmptyOptionalFields() throws {
let post = makeReaderPost()
post.permaLink = "https://example.com/minimal"
post.isSavedForLater = true
post.sortDate = Date()
try mainContext.save()

let fileURL = try XCTUnwrap(exporter.export(context: mainContext))
let data = try Data(contentsOf: fileURL)
let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
let posts = try XCTUnwrap(envelope["posts"] as? [[String: Any]])
let exported = posts[0]

XCTAssertNil(exported["featuredImageURL"])
XCTAssertNil(exported["tags"])
}

func testExportFileNameContainsDate() throws {
let post = makeReaderPost()
post.permaLink = "https://example.com/test"
post.isSavedForLater = true
post.sortDate = Date()
try mainContext.save()

let fileURL = try XCTUnwrap(exporter.export(context: mainContext))

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let todayString = dateFormatter.string(from: Date())

XCTAssertTrue(fileURL.lastPathComponent.contains(todayString))
XCTAssertEqual(fileURL.pathExtension, "json")
}

// MARK: - parseExportFile

func testParseExportFileReturnsPosts() throws {
let envelope = ReaderSavedPostsExporter.Envelope(
exportDate: "2026-04-23",
postCount: 2,
posts: [
makeExportedPost(url: "https://example.com/1", siteID: 100, postID: 1),
makeExportedPost(url: "https://example.com/2", siteID: 200, postID: 2)
],
appVersion: "Test 1.0"
)
let fileURL = try writeEnvelopeToTempFile(envelope)
let posts = try ReaderSavedPostsExporter.parseExportFile(at: fileURL)

XCTAssertEqual(posts.count, 2)
XCTAssertEqual(posts[0].url, "https://example.com/1")
}

func testParseExportFileThrowsForInvalidFormat() throws {
let json: [String: Any] = ["notPosts": true]
let fileURL = try writeJSONToTempFile(json)

XCTAssertThrowsError(try ReaderSavedPostsExporter.parseExportFile(at: fileURL))
}

func testParseExportFileThrowsForNonJSON() throws {
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("bad-\(UUID().uuidString).json")
try "not json".write(to: fileURL, atomically: true, encoding: .utf8)

XCTAssertThrowsError(try ReaderSavedPostsExporter.parseExportFile(at: fileURL))
}

// MARK: - Import filtering

func testImportSkipsPostsAlreadySaved() throws {
let existing = makeReaderPost()
existing.permaLink = "https://example.com/already-saved"
existing.isSavedForLater = true
existing.sortDate = Date()
try mainContext.save()

let posts = [makeExportedPost(url: "https://example.com/already-saved", siteID: 100, postID: 1)]

let expectation = expectation(description: "import completes")
ReaderSavedPostsExporter.importPosts(
posts,
coreDataStack: contextManager,
progress: { _, _ in },
completion: { result in
XCTAssertEqual(result.imported, 0)
XCTAssertEqual(result.skipped, 1)
XCTAssertEqual(result.failed, 0)
expectation.fulfill()
}
)

wait(for: [expectation], timeout: 1.0)
}

func testImportSkipsPostsWithMissingSiteID() {
let posts = [makeExportedPost(url: "https://example.com/no-site", siteID: nil, postID: 1)]

let expectation = expectation(description: "import completes")
ReaderSavedPostsExporter.importPosts(
posts,
coreDataStack: contextManager,
progress: { _, _ in },
completion: { result in
XCTAssertEqual(result.imported, 0)
XCTAssertEqual(result.skipped, 1)
XCTAssertEqual(result.failed, 0)
expectation.fulfill()
}
)

wait(for: [expectation], timeout: 1.0)
}

func testImportSkipsPostsWithMissingPostID() {
let posts = [makeExportedPost(url: "https://example.com/no-post-id", siteID: 100, postID: nil)]

let expectation = expectation(description: "import completes")
ReaderSavedPostsExporter.importPosts(
posts,
coreDataStack: contextManager,
progress: { _, _ in },
completion: { result in
XCTAssertEqual(result.imported, 0)
XCTAssertEqual(result.skipped, 1)
XCTAssertEqual(result.failed, 0)
expectation.fulfill()
}
)

wait(for: [expectation], timeout: 1.0)
}

func testImportSkipsPostsWithEmptyURL() {
let posts = [makeExportedPost(url: "", siteID: 100, postID: 1)]

let expectation = expectation(description: "import completes")
ReaderSavedPostsExporter.importPosts(
posts,
coreDataStack: contextManager,
progress: { _, _ in },
completion: { result in
XCTAssertEqual(result.imported, 0)
XCTAssertEqual(result.skipped, 1)
XCTAssertEqual(result.failed, 0)
expectation.fulfill()
}
)

wait(for: [expectation], timeout: 1.0)
}

func testImportReturnsEmptyResultForEmptyPostsList() {
let expectation = expectation(description: "import completes")
ReaderSavedPostsExporter.importPosts(
[],
coreDataStack: contextManager,
progress: { _, _ in },
completion: { result in
XCTAssertEqual(result.imported, 0)
XCTAssertEqual(result.skipped, 0)
XCTAssertEqual(result.failed, 0)
expectation.fulfill()
}
)

wait(for: [expectation], timeout: 1.0)
}

// MARK: - Round-trip (export -> parse)

func testExportThenParsePreservesAllFields() throws {
let post = makeReaderPost()
post.postTitle = "Round Trip"
post.permaLink = "https://example.com/round-trip"
post.authorDisplayName = "Author"
post.blogName = "Blog"
post.blogURL = "https://blog.example.com"
post.summary = "Summary text"
post.featuredImage = "https://example.com/img.jpg"
post.tags = "tag1, tag2"
post.siteID = 999
post.postID = 888
post.isExternal = true
post.isSavedForLater = true
post.sortDate = Date()
post.date_created_gmt = Date(timeIntervalSince1970: 1700000000)
try mainContext.save()

let fileURL = try XCTUnwrap(exporter.export(context: mainContext))
let posts = try ReaderSavedPostsExporter.parseExportFile(at: fileURL)

XCTAssertEqual(posts.count, 1)
let exported = posts[0]
XCTAssertEqual(exported.title, "Round Trip")
XCTAssertEqual(exported.url, "https://example.com/round-trip")
XCTAssertEqual(exported.author, "Author")
XCTAssertEqual(exported.siteName, "Blog")
XCTAssertEqual(exported.siteURL, "https://blog.example.com")
XCTAssertEqual(exported.summary, "Summary text")
XCTAssertEqual(exported.featuredImageURL, "https://example.com/img.jpg")
XCTAssertEqual(exported.tags, ["tag1", "tag2"])
XCTAssertEqual(exported.siteID, 999)
XCTAssertEqual(exported.postID, 888)
XCTAssertEqual(exported.isFeed, true)
XCTAssertNotNil(exported.date)
}
}

// MARK: - Helpers

private extension ReaderSavedPostsExporterTests {
func makeReaderPost() -> ReaderPost {
NSEntityDescription.insertNewObject(
forEntityName: "ReaderPost",
into: mainContext
) as! ReaderPost
}

func makeExportedPost(
url: String,
siteID: UInt?,
postID: UInt?,
isFeed: Bool = false
) -> ReaderSavedPostsExporter.ExportedPost {
ReaderSavedPostsExporter.ExportedPost(
title: "",
url: url,
author: "",
siteName: "",
siteURL: "",
date: nil,
summary: "",
tags: nil,
featuredImageURL: nil,
siteID: siteID,
postID: postID,
isFeed: isFeed
)
}

func writeJSONToTempFile(_ json: [String: Any]) throws -> URL {
let data = try JSONSerialization.data(withJSONObject: json)
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".json")
try data.write(to: fileURL)
return fileURL
}

func writeEnvelopeToTempFile(_ envelope: ReaderSavedPostsExporter.Envelope) throws -> URL {
let data = try JSONEncoder().encode(envelope)
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".json")
try data.write(to: fileURL)
return fileURL
}
}
9 changes: 9 additions & 0 deletions WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ import WordPressShared
case readerCommentTextCopied
case readerPostContextMenuButtonTapped
case readerAddSiteToFavoritesTapped
case readerSavedPostsSettingsShown
case readerSavedPostsExported
case readerSavedPostsImported

// Stats - Empty Stats nudges
case statsPublicizeNudgeShown
Expand Down Expand Up @@ -928,6 +931,12 @@ import WordPressShared
return "reader_post_context_menu_button_tapped"
case .readerAddSiteToFavoritesTapped:
return "reader_add_site_to_favorites_tapped"
case .readerSavedPostsSettingsShown:
return "reader_saved_posts_settings_shown"
case .readerSavedPostsExported:
return "reader_saved_posts_exported"
case .readerSavedPostsImported:
return "reader_saved_posts_imported"

// Stats - Empty Stats nudges
case .statsPublicizeNudgeShown:
Expand Down
Loading