diff --git a/Core/PARStore.h b/Core/PARStore.h index 1b9ed99..3636311 100644 --- a/Core/PARStore.h +++ b/Core/PARStore.h @@ -71,6 +71,12 @@ extern NSString *PARStoreDidSyncNotification; - (nullable NSString *)absolutePathForBlobPath:(NSString *)path; - (NSArray *)absolutePathsForBlobsPrefixedBy:(NSString *)prefix NS_SWIFT_NAME(absolutePaths(forBlobsPrefixedBy:)); - (void)enumerateBlobs:(void(^)(NSString *path))block; +- (BOOL)blobExistsAtPath:(NSString *)path; + +/// @name Registered Deletion Support +- (BOOL)deleteBlobAtPath:(NSString *)path registeringDeletion: (BOOL)registeringDeletion ignoreIfMissing: (BOOL)ignoreIfMissing error:(NSError **)error; +- (BOOL)blobIsRegisteredDeletedAtPath:(NSString *)path; +- (void)enumerateDeletedBlobs:(void(^)(NSString *blobPath, NSString *markerPath))block; /// @name Syncing - (void)sync; diff --git a/Core/PARStore.m b/Core/PARStore.m index e23b781..5e4d62d 100644 --- a/Core/PARStore.m +++ b/Core/PARStore.m @@ -30,6 +30,8 @@ NSString *const TimestampAttributeName = @"timestamp"; NSString *const ParentTimestampAttributeName = @"parentTimestamp"; +// tombstone constants +NSString *const TombstoneFileExtension = @"deleted"; // A subclass of NSFileCoordinator that doesn't use coordination. // This is used to disable coordination, for a performance boost when it is not needed. @@ -1382,7 +1384,48 @@ - (BOOL)writeBlobFromPath:(NSString *)sourcePath toPath:(NSString *)targetSubpat return YES; } +- (BOOL)writeTombstoneAtPath:(NSString *)tombstonePath forFileAtPath:(NSString *)filePath error:(NSError **)error +{ + if ([[NSFileManager defaultManager] fileExistsAtPath:tombstonePath]) + { + // if a tombstone already exists, we need not make it again + return YES; + } + + // the tombstone contains the original modification date and file size, along with the current time + NSDictionary* attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:error]; + NSDictionary* properties = @{ @"modified": attributes.fileModificationDate, @"size": @(attributes.fileSize), @"deleted": [NSDate date] }; + return [properties writeToFile:tombstonePath atomically:YES]; +} + + +- (BOOL)blobExistsAtPath:(NSString *)path +{ + NSURL *blobURL = [[self blobDirectoryURL] URLByAppendingPathComponent:path]; + + // if a tombstone file exists, return NO, regardless of the presence of the actual file + NSURL* tombstoneURL = [blobURL URLByAppendingPathExtension:TombstoneFileExtension]; + if ([[NSFileManager defaultManager] fileExistsAtPath:tombstoneURL.path]) + { + return NO; + } + + return [[NSFileManager defaultManager] fileExistsAtPath:blobURL.path]; +} + +- (BOOL)blobIsRegisteredDeletedAtPath:(NSString *)path +{ + NSURL *blobURL = [[self blobDirectoryURL] URLByAppendingPathComponent:path]; + NSURL* tombstoneURL = [blobURL URLByAppendingPathExtension:TombstoneFileExtension]; + return [[NSFileManager defaultManager] fileExistsAtPath:tombstoneURL.path]; +} + - (BOOL)deleteBlobAtPath:(NSString *)path error:(NSError **)error +{ + return [self deleteBlobAtPath:path registeringDeletion: NO ignoreIfMissing:NO error:error]; +} + +- (BOOL)deleteBlobAtPath:(NSString *)path registeringDeletion: (BOOL)registeringDeletion ignoreIfMissing: (BOOL)ignoreIfMissing error:(NSError **)error { // nil path = error if (path == nil) @@ -1407,24 +1450,53 @@ - (BOOL)deleteBlobAtPath:(NSString *)path error:(NSError **)error // otherwise blobs are stored in a special blob directory __block NSError *localError = nil; NSURL *fileURL = [[self blobDirectoryURL] URLByAppendingPathComponent:path]; + NSURL *tombstoneURL = [fileURL URLByAppendingPathExtension:TombstoneFileExtension]; + NSError *coordinatorError = nil; - [[self newFileCoordinator] coordinateWritingItemAtURL:fileURL options:NSFileCoordinatorWritingForReplacing error:&coordinatorError byAccessor:^(NSURL *newURL) + + [[self newFileCoordinator] coordinateWritingItemAtURL:fileURL options:NSFileCoordinatorWritingForReplacing writingItemAtURL:tombstoneURL options:NSFileCoordinatorWritingForReplacing error:&coordinatorError byAccessor:^(NSURL * _Nonnull newURL, NSURL * _Nonnull newTombstoneURL) { - // write to disk (overwrite any file that was at that same path before) - NSError *error = nil; - BOOL success = [[NSFileManager defaultManager] removeItemAtURL:fileURL error:&error]; - if (!success) - localError = [NSError errorWithObject:self code:__LINE__ localizedDescription:[NSString stringWithFormat:@"Could not delete data blob at path '%@'", newURL.path] underlyingError:error]; - }]; + NSError *error = nil; + BOOL success = YES; + BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:newURL.path]; + + if (!(ignoreIfMissing || fileExists)) { + success = NO; + localError = [NSError errorWithObject:self code:__LINE__ localizedDescription:[NSString stringWithFormat:@"Could not delete non-existent data blob at path '%@'", newURL.path] underlyingError:nil]; + } + + if (success && registeringDeletion && fileExists) { + // write tombstone + success = [self writeTombstoneAtPath:newTombstoneURL.path forFileAtPath:newURL.path error:&error]; + if (!success) + { + localError = [NSError errorWithObject:self code:__LINE__ localizedDescription:[NSString stringWithFormat:@"Could not create tombstone for data blob at path '%@'", newURL.path] underlyingError:error]; + } + } + + if (success && fileExists) + { + success = [[NSFileManager defaultManager] removeItemAtURL:fileURL error:&error]; + if (!success) + { + localError = [NSError errorWithObject:self code:__LINE__ localizedDescription:[NSString stringWithFormat:@"Could not delete data blob at path '%@'", newURL.path] underlyingError:error]; + } + } + }]; // error handling if (coordinatorError && !localError) + { localError = coordinatorError; + } + if (localError) { ErrorLog(@"Error deleting blob: %@", localError); if (error != NULL) + { *error = localError; + } return NO; } return YES; @@ -1456,6 +1528,26 @@ - (NSData *)blobDataAtPath:(NSString *)path error:(NSError **)error; // otherwise blobs are stored in a special blob directory __block NSError *localError = nil; NSURL *fileURL = [[self blobDirectoryURL] URLByAppendingPathComponent:path]; + + // check first for a tombstone, which indicates that the + // blob had been deleted and should be ignored + NSURL *tombstoneURL = [fileURL URLByAppendingPathExtension:TombstoneFileExtension]; + if ([[NSFileManager defaultManager] fileExistsAtPath: tombstoneURL.path]) { + NSString *description = [NSString stringWithFormat:@"Blob at path '%@' has a tombstone, indicating that it has been deleted.", self.storeURL.path]; + ErrorLog(@"%@", description); + if (error != NULL) + { + *error = [NSError errorWithObject:self code:__LINE__ localizedDescription:description underlyingError:nil]; + } + + // we attempt to clean up sync errors here, by deleting the data file if it still exists + // most times this call will fail because the file won't exist, and if it fails for another + // reason there's probably nothing we can do, so we ignore any errors + [[NSFileManager defaultManager] removeItemAtURL:fileURL error:NULL]; + + return nil; + } + NSError *coordinatorError = nil; __block NSData *data = nil; [[self newFileCoordinator] coordinateReadingItemAtURL:fileURL options:NSFileCoordinatorReadingWithoutChanges error:&coordinatorError byAccessor:^(NSURL *newURL) @@ -1509,31 +1601,79 @@ - (void)enumerateBlobs:(void(^)(NSString *path))block } else { - __block NSArray *urls; + __block NSMutableArray *urls = [NSMutableArray array]; + __block NSMutableSet *tombstones = [NSMutableSet set]; [[self newFileCoordinator] coordinateReadingItemAtURL:[self blobDirectoryURL] options:NSFileCoordinatorReadingWithoutChanges error:NULL byAccessor:^(NSURL *newURL) { NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtURL:[self blobDirectoryURL] includingPropertiesForKeys:nil options:(NSDirectoryEnumerationSkipsPackageDescendants|NSDirectoryEnumerationSkipsHiddenFiles|NSDirectoryEnumerationSkipsSubdirectoryDescendants) errorHandler:nil]; NSFileManager *fileManager = [[NSFileManager alloc] init]; - urls = [enumerator.allObjects filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id object, NSDictionary *bindings) { - NSURL *url = object; + for(NSURL* url in enumerator) { BOOL isDir = false; - return [fileManager fileExistsAtPath:url.path isDirectory:&isDir] && !isDir; - }]]; + if ([fileManager fileExistsAtPath:url.path isDirectory:&isDir] && isDir) continue; + if ([url.pathExtension isEqualToString: TombstoneFileExtension]) { + [tombstones addObject:[url URLByDeletingPathExtension]]; + } else { + [urls addObject:url]; + } + } }]; NSUInteger prefixLength = self.blobDirectoryURL.path.length+1; // +1 is for the last slash for (NSURL *url in urls) { + if ([tombstones containsObject:url]) { + // a tombstone file exists for this data file, indicating that it has been deleted + // we skip it for the enumeration, and we attempt to clean up by deleting the data file + // (we ignore deletion errors as this method doesn't return success/failure, and the + // underlying cause of the mismatch is probably something that needs fixing by a higher + // layer anyway) + [[NSFileManager defaultManager] removeItemAtURL:url error:NULL]; + } else { + // Resolving symbolic link here, because on iOS at least, the directory enumerator + // uses a sym linked "private" folder, causing the path to be different to what comes + // out for the blobDirectoryURL. + NSString *absolutePath = [url URLByResolvingSymlinksInPath].path; + NSString *relativePath = [absolutePath substringFromIndex:prefixLength]; + block(relativePath); + } + } + } +} + +- (void)enumerateDeletedBlobs:(void(^)(NSString *blobPath, NSString *markerPath))block +{ + if (!self._inMemory) + { + __block NSMutableSet *tombstones = [NSMutableSet set]; + [[self newFileCoordinator] coordinateReadingItemAtURL:[self blobDirectoryURL] options:NSFileCoordinatorReadingWithoutChanges error:NULL byAccessor:^(NSURL *newURL) + { + NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtURL:[self blobDirectoryURL] includingPropertiesForKeys:nil options:(NSDirectoryEnumerationSkipsPackageDescendants|NSDirectoryEnumerationSkipsHiddenFiles|NSDirectoryEnumerationSkipsSubdirectoryDescendants) errorHandler:nil]; + NSFileManager *fileManager = [[NSFileManager alloc] init]; + for(NSURL* url in enumerator) { + BOOL isDir = false; + if (![fileManager fileExistsAtPath:url.path isDirectory:&isDir] || isDir) continue; + if ([url.pathExtension isEqualToString: TombstoneFileExtension]) { + [tombstones addObject:url]; + } + } + }]; + + NSUInteger prefixLength = self.blobDirectoryURL.path.length+1; // +1 is for the last slash + for (NSURL *url in tombstones) { // Resolving symbolic link here, because on iOS at least, the directory enumerator // uses a sym linked "private" folder, causing the path to be different to what comes // out for the blobDirectoryURL. NSString *absolutePath = [url URLByResolvingSymlinksInPath].path; - NSString *relativePath = [absolutePath substringFromIndex:prefixLength]; - block(relativePath); + NSString *relativeTombstonePath = [absolutePath substringFromIndex:prefixLength]; + NSString *relativePath = [relativeTombstonePath stringByDeletingPathExtension]; + + // we pass back both the path to the datafile and the path to the tombstone file + // since the relationship between the two is an implementation detail + // (right now it's just an extra file extension, but that may not always be true) + block(relativePath, relativeTombstonePath); } } } - #pragma mark - Syncing - (void)applySyncChangeWithValues:(NSDictionary *)values timestamps:(NSDictionary *)timestamps diff --git a/PARStore.xcodeproj/project.pbxproj b/PARStore.xcodeproj/project.pbxproj index b9eeb7f..fb2a055 100644 --- a/PARStore.xcodeproj/project.pbxproj +++ b/PARStore.xcodeproj/project.pbxproj @@ -46,6 +46,8 @@ 56EAE1BB16E24E7300A7F31F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 56EAE1B816E24E7300A7F31F /* main.m */; }; 56EAE1BE16E24EAA00A7F31F /* PARStore.m in Sources */ = {isa = PBXBuildFile; fileRef = 56EAE1BD16E24EAA00A7F31F /* PARStore.m */; }; 56FA3D771970359C00BF81D3 /* libsqlite3.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 56FA3D761970359C00BF81D3 /* libsqlite3.dylib */; }; + 8BED6AEB26824978004639DF /* PARStoreBlobTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 8BED6AEA26824978004639DF /* PARStoreBlobTests.m */; }; + 8BED6AEC26824978004639DF /* PARStoreBlobTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 8BED6AEA26824978004639DF /* PARStoreBlobTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -114,6 +116,7 @@ 56EAE1BC16E24EAA00A7F31F /* PARStore.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PARStore.h; path = Core/PARStore.h; sourceTree = SOURCE_ROOT; }; 56EAE1BD16E24EAA00A7F31F /* PARStore.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; name = PARStore.m; path = Core/PARStore.m; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; 56FA3D761970359C00BF81D3 /* libsqlite3.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libsqlite3.dylib; path = usr/lib/libsqlite3.dylib; sourceTree = SDKROOT; }; + 8BED6AEA26824978004639DF /* PARStoreBlobTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PARStoreBlobTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -265,6 +268,7 @@ 56C7EDFA16E26D0700FFBBF2 /* PARTestCase.h */, 56C7EDFB16E26D0700FFBBF2 /* PARTestCase.m */, 56EAE19416E24C7500A7F31F /* PARStoreTests.m */, + 8BED6AEA26824978004639DF /* PARStoreBlobTests.m */, 561148E6196E952800F488F2 /* PARSQLiteTests.m */, 56E196E41B448BE600A51AB0 /* PARDispatchQueueTests.m */, 56EAE18E16E24C7500A7F31F /* Supporting Files */, @@ -456,6 +460,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8BED6AEC26824978004639DF /* PARStoreBlobTests.m in Sources */, 56C7EDF716E2687F00FFBBF2 /* PARStoreTests.m in Sources */, 56C7EDFD16E26D0700FFBBF2 /* PARTestCase.m in Sources */, ); @@ -480,6 +485,7 @@ buildActionMask = 2147483647; files = ( 56EAE19516E24C7500A7F31F /* PARStoreTests.m in Sources */, + 8BED6AEB26824978004639DF /* PARStoreBlobTests.m in Sources */, 56E196E61B448CA300A51AB0 /* PARDispatchQueueTests.m in Sources */, 56C7EDFC16E26D0700FFBBF2 /* PARTestCase.m in Sources */, 561148E7196E952800F488F2 /* PARSQLiteTests.m in Sources */, diff --git a/Tests/PARStoreBlobTests.m b/Tests/PARStoreBlobTests.m new file mode 100644 index 0000000..31f3586 --- /dev/null +++ b/Tests/PARStoreBlobTests.m @@ -0,0 +1,202 @@ +// PARStoreBlobTests +// Created by Sam Deane on 22/06/21. +// All code (c) 2021 - present day, Elegant Chaos Limited. + +#import "PARTestCase.h" +#import "PARStoreExample.h" +#import "PARNotificationSemaphore.h" + +extern NSString *const TombstoneFileExtension; // normally private, but exposed for testing + +@interface PARStoreBlobTests : PARTestCase + +@end + + +@implementation PARStoreBlobTests + +- (void)setUp +{ + [super setUp]; + + // Set-up code here. +} + +- (void)tearDown +{ + // Tear-down code here. + + [super tearDown]; +} + +- (NSString *)deviceIdentifierForTest +{ + return @"948E9EEE-3398-4DD7-9183-C56866EF2350"; +} + +#pragma mark - + +- (void)testDeletion +{ + // deleting a blob with the old API should work as before + + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob" error:&error]); + + NSString *blobPath = [store absolutePathForBlobPath: @"blob"]; + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath: blobPath]); + + XCTAssertTrue([store deleteBlobAtPath:@"blob" error:&error]); + XCTAssertFalse([store blobExistsAtPath:@"blob"]); + + // blob file should have gone + XCTAssertFalse([[NSFileManager defaultManager] fileExistsAtPath: blobPath]); + + // tombstone file should not have appeared in its place + NSString *tombstonePath = [blobPath stringByAppendingPathExtension: TombstoneFileExtension]; + XCTAssertFalse([[NSFileManager defaultManager] fileExistsAtPath: tombstonePath]); + XCTAssertFalse([store blobIsRegisteredDeletedAtPath:@"blob"]); + + [store tearDownNow]; +} + +- (void)testDeletionWithTombstone +{ + // deleting a blob with the new API should result in a tombstone file + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob" error:&error]); + + NSString *blobPath = [store absolutePathForBlobPath: @"blob"]; + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath: blobPath]); + + XCTAssertTrue([store deleteBlobAtPath:@"blob" registeringDeletion: YES error:&error]); + XCTAssertFalse([store blobExistsAtPath:@"blob"]); + + // blob file should have gone + XCTAssertFalse([[NSFileManager defaultManager] fileExistsAtPath: blobPath]); + + // tombstone file should have appeared in its place + NSString *tombstonePath = [blobPath stringByAppendingPathExtension: TombstoneFileExtension]; + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath: tombstonePath]); + XCTAssertTrue([store blobIsRegisteredDeletedAtPath:@"blob"]); + + [store tearDownNow]; +} + + +- (void)testBlobExists +{ + // exists should produce the right result before/after creation of a blob file + + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + + XCTAssertFalse([store blobExistsAtPath:@"blob"]); + + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob" error:&error]); + XCTAssertTrue([store blobExistsAtPath:@"blob"]); + + [store tearDownNow]; +} + + +- (void)testTombstoneSuppressesFileExistence +{ + // if there's a tombstone file present, exists should return NO even if the actual blob + // file is still present + + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob" error:&error]); + XCTAssertTrue([store blobExistsAtPath:@"blob"]); + + // fake the presence of a tombstone, to simulate a situation where a partial + // synchronisation has caused it to exist along with the blob + NSString *tombstonePath = [[store absolutePathForBlobPath: @"blob"] stringByAppendingPathExtension: TombstoneFileExtension]; + [[@"foobar" dataUsingEncoding:NSUTF8StringEncoding] writeToFile:tombstonePath atomically:YES]; + + XCTAssertFalse([store blobExistsAtPath:@"blob"]); + + [store tearDownNow]; +} + +- (void)testTombstoneSuppressesData +{ + // if there is a tombstone file present, no data should be returned even if the actual blob + // file is still present + + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob" error:&error]); + + // fake the presence of a tombstone, to simulate a situation where a partial + // synchronisation has caused it to exist along with the blob + NSString *tombstonePath = [[store absolutePathForBlobPath: @"blob"] stringByAppendingPathExtension: TombstoneFileExtension]; + [[@"foobar" dataUsingEncoding:NSUTF8StringEncoding] writeToFile:tombstonePath atomically:YES]; + + XCTAssertNil([store blobDataAtPath:@"blob" error:&error]); + + [store tearDownNow]; +} + +- (void)testTombstoneSuppressesEnumeration +{ + // tombstone files shouldn't be included in the enumeration + // the presence of a tombstone file should also cause the corresponding data file to be skipped + // from the enumeration (if it still exists) + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob1" error:&error]); + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob2" error:&error]); + + // fake the presence of a tombstone, to simulate a situation where a partial + // synchronisation has caused it to exist along with the blob + NSString *tombstonePath = [[store absolutePathForBlobPath: @"blob1"] stringByAppendingPathExtension: TombstoneFileExtension]; + [[@"foobar" dataUsingEncoding:NSUTF8StringEncoding] writeToFile:tombstonePath atomically:YES]; + + __block int count = 1; + [store enumerateBlobs:^(NSString *blobPath) { + ++count; + XCTAssertEqual(blobPath, @"blob2"); + }]; + XCTAssertEqual(count, 1); + + [store tearDownNow]; +} + +- (void)testEnumeratingTombstones +{ + // we should be called back with both the original data file paths, and the paths to the tombstones + NSError *error = nil; + NSURL *url = [[self urlWithUniqueTmpDirectory] URLByAppendingPathComponent:@"doc.parstore"]; + PARStore *store = [PARStore storeWithURL:url deviceIdentifier:[self deviceIdentifierForTest]]; + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob1" error:&error]); + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob2" error:&error]); + XCTAssertTrue([store writeBlobData:[@"test" dataUsingEncoding:NSUTF8StringEncoding] toPath:@"blob3" error:&error]); + + XCTAssertTrue([store deleteBlobAtPath:@"blob1" registeringDeletion: YES error:&error]); + XCTAssertTrue([store deleteBlobAtPath:@"blob3" registeringDeletion: YES error:&error]); + + __block int count = 0; + NSArray* expected = @[@"blob1", @"blob3"]; + NSArray* expectedTombstones = @[@"blob1.deleted", @"blob3.deleted"]; + [store enumerateDeletedBlobs:^(NSString *blobPath, NSString *markerPath) { + ++count; + XCTAssertTrue([expected containsObject: blobPath]); + XCTAssertTrue([expectedTombstones containsObject: markerPath]); + }]; + XCTAssertEqual(count, 2); + + [store tearDownNow]; +} + + +@end