Skip to content

Commit

Permalink
Add in-memory FileStore for unit tests (#1465)
Browse files Browse the repository at this point in the history
Previously, we were using MockK to stub out the file store in unit tests of photo
code. Replace it with a simple in-memory implementation so tests don't have to
configure the test doubles.
  • Loading branch information
sgrimm authored Nov 9, 2023
1 parent 3bb567f commit acfdc30
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.terraformation.backend.file

import java.io.ByteArrayInputStream
import java.io.InputStream
import java.net.URI
import java.nio.file.FileAlreadyExistsException
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.time.Instant
import org.junit.jupiter.api.fail
import org.springframework.http.MediaType

class InMemoryFileStore(private val pathGenerator: PathGenerator? = null) : FileStore {
private var counter = 0
private val files = mutableMapOf<URI, ByteArray>()
private val deletedFiles = mutableSetOf<URI>()

fun assertFileNotExists(
url: URI,
message: String = "$url was found in file store but shouldn't have been"
) {
if (url in files) {
fail(message)
}
}

fun assertFileExists(url: URI) {
if (url !in files) {
fail("$url not found in file store")
}
}

fun assertFileWasDeleted(url: URI) {
if (url !in deletedFiles) {
fail("$url was not deleted from file store")
}
}

override fun delete(url: URI) {
files.remove(url) ?: throw NoSuchFileException("$url")
deletedFiles.add(url)
}

override fun read(url: URI): SizedInputStream {
val bytes = getFile(url)
return SizedInputStream(
ByteArrayInputStream(bytes), bytes.size.toLong(), MediaType.APPLICATION_OCTET_STREAM)
}

override fun size(url: URI): Long {
return getFile(url).size.toLong()
}

override fun write(url: URI, contents: InputStream) {
if (url in files) {
throw FileAlreadyExistsException("$url")
}

files[url] = contents.readAllBytes()
}

override fun canAccept(url: URI): Boolean = true

override fun getUrl(path: Path): URI = URI("file:///$path")

override fun newUrl(timestamp: Instant, category: String, contentType: String): URI {
return if (pathGenerator != null) {
getUrl(pathGenerator.generatePath(timestamp, category, contentType))
} else {
URI("file:///$timestamp/$category/${counter++}")
}
}

private fun getFile(url: URI) = files[url] ?: throw NoSuchFileException("$url")
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import com.terraformation.backend.db.nursery.BatchPhotoId
import com.terraformation.backend.db.nursery.tables.pojos.BatchPhotosRow
import com.terraformation.backend.db.nursery.tables.references.BATCH_PHOTOS
import com.terraformation.backend.file.FileService
import com.terraformation.backend.file.FileStore
import com.terraformation.backend.file.InMemoryFileStore
import com.terraformation.backend.file.SizedInputStream
import com.terraformation.backend.file.ThumbnailStore
import com.terraformation.backend.file.model.FileMetadata
Expand All @@ -25,8 +25,6 @@ import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import java.net.URI
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
Expand All @@ -45,7 +43,7 @@ internal class BatchPhotoServiceTest : DatabaseTest(), RunsAsUser {
override val tablesToResetSequences = listOf(BATCH_PHOTOS)

private val clock = TestClock()
private val fileStore: FileStore = mockk()
private val fileStore = InMemoryFileStore()
private val thumbnailStore: ThumbnailStore = mockk()
private val fileService: FileService by lazy {
FileService(
Expand All @@ -57,7 +55,6 @@ internal class BatchPhotoServiceTest : DatabaseTest(), RunsAsUser {

private val metadata = FileMetadata.of(MediaType.IMAGE_JPEG_VALUE, "filename", 123L)
private val batchId: BatchId by lazy { insertBatch() }
private var storageUrlCount = 0

@BeforeEach
fun setUp() {
Expand All @@ -66,9 +63,6 @@ internal class BatchPhotoServiceTest : DatabaseTest(), RunsAsUser {
insertSpecies()
insertFacility(type = FacilityType.Nursery)

every { fileStore.delete(any()) } just Runs
every { fileStore.newUrl(any(), any(), any()) } answers { URI("${++storageUrlCount}") }
every { fileStore.write(any(), any()) } just Runs
every { thumbnailStore.deleteThumbnails(any()) } just Runs
every { user.canReadBatch(any()) } returns true
every { user.canUpdateBatch(any()) } returns true
Expand Down Expand Up @@ -108,8 +102,6 @@ internal class BatchPhotoServiceTest : DatabaseTest(), RunsAsUser {
val content = Random.nextBytes(10)
val fileId = storePhoto(content = content)

every { fileStore.read(URI("1")) } returns SizedInputStream(content.inputStream(), 10L)

val inputStream = service.readPhoto(batchId, fileId)
assertArrayEquals(content, inputStream.readAllBytes(), "File content")
}
Expand Down Expand Up @@ -200,7 +192,7 @@ internal class BatchPhotoServiceTest : DatabaseTest(), RunsAsUser {
id = BatchPhotoId(1))),
batchPhotosDao.findAll())

verify { fileStore.delete(storageUrl) }
fileStore.assertFileWasDeleted(storageUrl)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import com.terraformation.backend.db.default_schema.OrganizationId
import com.terraformation.backend.db.nursery.WithdrawalId
import com.terraformation.backend.db.nursery.tables.pojos.WithdrawalPhotosRow
import com.terraformation.backend.file.FileService
import com.terraformation.backend.file.FileStore
import com.terraformation.backend.file.InMemoryFileStore
import com.terraformation.backend.file.SizedInputStream
import com.terraformation.backend.file.ThumbnailStore
import com.terraformation.backend.file.model.FileMetadata
Expand All @@ -22,7 +22,6 @@ import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import java.net.URI
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
Expand All @@ -37,7 +36,7 @@ import org.springframework.http.MediaType
internal class WithdrawalPhotoServiceTest : DatabaseTest(), RunsAsUser {
override val user = mockUser()

private val fileStore: FileStore = mockk()
private val fileStore = InMemoryFileStore()
private val thumbnailStore: ThumbnailStore = mockk()
private val fileService: FileService by lazy {
FileService(
Expand All @@ -49,17 +48,13 @@ internal class WithdrawalPhotoServiceTest : DatabaseTest(), RunsAsUser {

private val metadata = FileMetadata.of(MediaType.IMAGE_JPEG_VALUE, "filename", 123L)
private val withdrawalId: WithdrawalId by lazy { insertWithdrawal() }
private var storageUrlCount = 0

@BeforeEach
fun setUp() {
insertUser()
insertOrganization()
insertFacility(type = FacilityType.Nursery)

every { fileStore.delete(any()) } just Runs
every { fileStore.newUrl(any(), any(), any()) } answers { URI("${++storageUrlCount}") }
every { fileStore.write(any(), any()) } just Runs
every { thumbnailStore.deleteThumbnails(any()) } just Runs
every { user.canCreateWithdrawalPhoto(any()) } returns true
every { user.canReadWithdrawal(any()) } returns true
Expand All @@ -77,8 +72,6 @@ internal class WithdrawalPhotoServiceTest : DatabaseTest(), RunsAsUser {
val content = Random.nextBytes(10)
val fileId = storePhoto(content = content)

every { fileStore.read(URI("1")) } returns SizedInputStream(content.inputStream(), 10L)

val inputStream = service.readPhoto(withdrawalId, fileId)
assertArrayEquals(content, inputStream.readAllBytes(), "File content")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.terraformation.backend.RunsAsUser
import com.terraformation.backend.TestClock
import com.terraformation.backend.TestEventPublisher
import com.terraformation.backend.assertIsEventListener
import com.terraformation.backend.config.TerrawareServerConfig
import com.terraformation.backend.customer.event.OrganizationDeletionStartedEvent
import com.terraformation.backend.customer.model.TerrawareUser
import com.terraformation.backend.db.AccessionNotFoundException
Expand All @@ -15,8 +14,7 @@ import com.terraformation.backend.db.default_schema.tables.pojos.FilesRow
import com.terraformation.backend.db.seedbank.AccessionId
import com.terraformation.backend.db.seedbank.tables.pojos.AccessionPhotosRow
import com.terraformation.backend.file.FileService
import com.terraformation.backend.file.FileStore
import com.terraformation.backend.file.LocalFileStore
import com.terraformation.backend.file.InMemoryFileStore
import com.terraformation.backend.file.PathGenerator
import com.terraformation.backend.file.SizedInputStream
import com.terraformation.backend.file.ThumbnailStore
Expand All @@ -30,20 +28,16 @@ import io.mockk.spyk
import io.mockk.verify
import java.io.ByteArrayInputStream
import java.net.URI
import java.nio.file.Files
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.time.ZoneOffset
import java.time.ZonedDateTime
import kotlin.io.path.Path
import kotlin.io.path.invariantSeparatorsPathString
import kotlin.random.Random
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
Expand All @@ -52,8 +46,7 @@ import org.springframework.security.access.AccessDeniedException

class PhotoRepositoryTest : DatabaseTest(), RunsAsUser {
private lateinit var accessionStore: AccessionStore
private val config: TerrawareServerConfig = mockk()
private lateinit var fileStore: FileStore
private lateinit var fileStore: InMemoryFileStore
private lateinit var pathGenerator: PathGenerator
private lateinit var fileService: FileService
private val random: Random = mockk()
Expand All @@ -62,9 +55,7 @@ class PhotoRepositoryTest : DatabaseTest(), RunsAsUser {

override val user: TerrawareUser = mockUser()

private lateinit var photoPath: Path
private lateinit var photoStorageUrl: URI
private lateinit var tempDir: Path

private val accessionId = AccessionId(12345)
private val accessionNumber = "ZYXWVUTSRQPO"
Expand All @@ -91,17 +82,12 @@ class PhotoRepositoryTest : DatabaseTest(), RunsAsUser {
mockk(),
)

tempDir = Files.createTempDirectory(javaClass.simpleName)

every { config.photoDir } returns tempDir

every { random.nextLong() } returns 0x0123456789abcdef
pathGenerator = PathGenerator(random)
fileStore = LocalFileStore(config, pathGenerator)
fileStore = InMemoryFileStore(pathGenerator)

val relativePath = Path("2021", "02", "03", "accession", "040506-0123456789ABCDEF.jpg")

photoPath = tempDir.resolve(relativePath)
photoStorageUrl = URI("file:///${relativePath.invariantSeparatorsPathString}")

every { user.canReadAccession(any()) } returns true
Expand All @@ -114,22 +100,17 @@ class PhotoRepositoryTest : DatabaseTest(), RunsAsUser {
insertAccession(id = accessionId, number = accessionNumber)
}

@AfterEach
fun deleteTemporaryDirectory() {
assertTrue(tempDir.toFile().deleteRecursively(), "Deleting temporary directory")
}

@Test
fun `storePhoto writes file and database row`() {
val photoData = Random(System.currentTimeMillis()).nextBytes(10)

repository.storePhoto(accessionId, photoData.inputStream(), metadata)
fileStore.assertFileExists(photoStorageUrl)

val expectedAccessionPhoto = AccessionPhotosRow(accessionId = accessionId)

assertTrue(Files.exists(photoPath), "Photo file $photoPath exists")
assertArrayEquals(photoData, Files.readAllBytes(photoPath), "File contents")
val actualPhotoData = fileStore.read(photoStorageUrl)
assertArrayEquals(photoData, actualPhotoData.readAllBytes(), "File contents")

val expectedAccessionPhoto = AccessionPhotosRow(accessionId = accessionId)
val actualAccessionPhoto = accessionPhotosDao.fetchByAccessionId(accessionId).first()
assertEquals(expectedAccessionPhoto, actualAccessionPhoto.copy(fileId = null))
}
Expand All @@ -154,7 +135,7 @@ class PhotoRepositoryTest : DatabaseTest(), RunsAsUser {
every { random.nextLong() } returns 1
repository.storePhoto(accessionId, photoData2.inputStream(), metadata)

assertFalse(Files.exists(photoPath), "Earlier photo file should have been deleted")
fileStore.assertFileNotExists(photoStorageUrl, "Earlier photo file should have been deleted")
assertEquals(1, accessionPhotosDao.fetchByAccessionId(accessionId).size, "Number of photos")

val stream = repository.readPhoto(accessionId, filename)
Expand Down

0 comments on commit acfdc30

Please sign in to comment.