package s3.website import java.io.File import java.util.concurrent.atomic.AtomicInteger import com.amazonaws.AmazonServiceException import com.amazonaws.services.cloudfront.AmazonCloudFront import com.amazonaws.services.cloudfront.model.{CreateInvalidationRequest, CreateInvalidationResult, TooManyInvalidationsInProgressException} import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.model._ import org.apache.commons.codec.digest.DigestUtils._ import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils._ import org.mockito.Mockito._ import org.mockito.invocation.InvocationOnMock import org.mockito.stubbing.Answer import org.mockito.{ArgumentCaptor, Matchers, Mockito} import org.specs2.mutable.{BeforeAfter, Specification} import org.specs2.specification.Scope import s3.website.CloudFront.CloudFrontSetting import s3.website.UploadHelper.DELETE_NOTHING_MAGIC_WORD import s3.website.Push.{CliArgs} import s3.website.S3.S3Setting import s3.website.model.Config.S3_website_yml import s3.website.model.Ssg.automaticallySupportedSiteGenerators import s3.website.model._ import scala.collection.JavaConversions._ import scala.concurrent.duration._ import scala.util.Random class S3WebsiteSpec extends Specification { "gzip: true" should { "update a gzipped S3 object if the contents has changed" in new BasicSetup { config = "gzip: true" setLocalFileWithContent(("styles.css", "

hi again

")) setS3Files(S3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push sentPutObjectRequest.getKey must equalTo("styles.css") } "not update a gzipped S3 object if the contents has not changed" in new BasicSetup { config = "gzip: true" setLocalFileWithContent(("styles.css", "

hi

")) setS3Files(S3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push noUploadsOccurred must beTrue } } """ gzip: - .xml """ should { "update a gzipped S3 object if the contents has changed" in new BasicSetup { config = """ |gzip: | - .xml """.stripMargin setLocalFileWithContent(("file.xml", "

hi again

")) setS3Files(S3File("file.xml", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push sentPutObjectRequest.getKey must equalTo("file.xml") } } "push" should { "not upload a file if it has not changed" in new BasicSetup { setLocalFileWithContent(("index.html", "
hello
")) setS3Files(S3File("index.html", md5Hex("
hello
"))) push noUploadsOccurred must beTrue } "update a file if it has changed" in new BasicSetup { setLocalFileWithContent(("index.html", "

old text

")) setS3Files(S3File("index.html", md5Hex("

new text

"))) push sentPutObjectRequest.getKey must equalTo("index.html") } "create a file if does not exist on S3" in new BasicSetup { setLocalFile("index.html") push sentPutObjectRequest.getKey must equalTo("index.html") } "delete files that are on S3 but not on local file system" in new BasicSetup { setS3Files(S3File("old.html", md5Hex("

old text

"))) push sentDelete must equalTo("old.html") } "try again if the upload fails" in new BasicSetup { setLocalFile("index.html") uploadFailsAndThenSucceeds(howManyFailures = 5) push verify(amazonS3Client, times(6)).putObject(Matchers.any(classOf[PutObjectRequest])) } "not try again if the upload fails on because of invalid credentials" in new BasicSetup { setLocalFile("index.html") when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow { val e = new AmazonServiceException("your credentials are incorrect") e.setStatusCode(403) e } push verify(amazonS3Client, times(1)).putObject(Matchers.any(classOf[PutObjectRequest])) } "try again if the request times out" in new BasicSetup { var attempt = 0 when(amazonS3Client putObject Matchers.any(classOf[PutObjectRequest])) thenAnswer new Answer[PutObjectResult] { def answer(invocation: InvocationOnMock) = { attempt += 1 if (attempt < 2) { val e = new AmazonServiceException("Too long a request") e.setStatusCode(400) e.setErrorCode("RequestTimeout") throw e } else { new PutObjectResult } } } setLocalFile("index.html") val exitStatus = push verify(amazonS3Client, times(2)).putObject(Matchers.any(classOf[PutObjectRequest])) } "try again if the delete fails" in new BasicSetup { setS3Files(S3File("old.html", md5Hex("

old text

"))) deleteFailsAndThenSucceeds(howManyFailures = 5) push verify(amazonS3Client, times(6)).deleteObject(Matchers.anyString(), Matchers.anyString()) } "try again if the object listing fails" in new BasicSetup { setS3Files(S3File("old.html", md5Hex("

old text

"))) objectListingFailsAndThenSucceeds(howManyFailures = 5) push verify(amazonS3Client, times(6)).listObjects(Matchers.any(classOf[ListObjectsRequest])) } } "push with CloudFront" should { "invalidate the updated CloudFront items" in new BasicSetup { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFiles("css/test.css", "articles/index.html") setOutdatedS3Keys("css/test.css", "articles/index.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/css/test.css" :: "/articles/index.html" :: Nil).sorted) } "not send CloudFront invalidation requests on new objects" in new BasicSetup { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("newfile.js") push noInvalidationsOccurred must beTrue } "not send CloudFront invalidation requests on redirect objects" in new BasicSetup { config = """ |cloudfront_distribution_id: EGM1J2JJX9Z |redirects: | /index.php: index.html """.stripMargin push noInvalidationsOccurred must beTrue } "retry CloudFront responds with TooManyInvalidationsInProgressException" in new BasicSetup { setTooManyInvalidationsInProgress(4) config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push must equalTo(0) // The retries should finally result in a success sentInvalidationRequests.length must equalTo(4) } "retry if CloudFront is temporarily unreachable" in new BasicSetup { invalidationsFailAndThenSucceed(5) config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push sentInvalidationRequests.length must equalTo(6) } "encode unsafe characters in the keys" in new BasicSetup { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("articles/arnold's file.html") setOutdatedS3Keys("articles/arnold's file.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/arnold's%20file.html" :: Nil).sorted) } "invalidate the root object '/' if a top-level object is updated or deleted" in new BasicSetup { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("maybe-index.html") setOutdatedS3Keys("maybe-index.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/" :: "/maybe-index.html" :: Nil).sorted) } } "cloudfront_invalidate_root: true" should { "convert CloudFront invalidation paths with the '/index.html' suffix into '/'" in new BasicSetup { config = """ |cloudfront_distribution_id: EGM1J2JJX9Z |cloudfront_invalidate_root: true """.stripMargin setLocalFile("articles/index.html") setOutdatedS3Keys("articles/index.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/" :: Nil).sorted) } } "a site with over 1000 items" should { "split the CloudFront invalidation requests into batches of 1000 items" in new BasicSetup { val files = (1 to 1002).map { i => s"lots-of-files/file-$i"} config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFiles(files:_*) setOutdatedS3Keys(files:_*) push sentInvalidationRequests.length must equalTo(2) sentInvalidationRequests(0).getInvalidationBatch.getPaths.getItems.length must equalTo(1000) sentInvalidationRequests(1).getInvalidationBatch.getPaths.getItems.length must equalTo(2) } } "push exit status" should { "be 0 all uploads succeed" in new BasicSetup { setLocalFiles("file.txt") push must equalTo(0) } "be 1 if any of the uploads fails" in new BasicSetup { setLocalFiles("file.txt") when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed")) push must equalTo(1) } "be 1 if any of the redirects fails" in new BasicSetup { config = """ |redirects: | index.php: /index.html """.stripMargin when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed")) push must equalTo(1) } "be 0 if CloudFront invalidations and uploads succeed"in new BasicSetup { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push must equalTo(0) } "be 1 if CloudFront is unreachable or broken"in new BasicSetup { setCloudFrontAsInternallyBroken() config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push must equalTo(1) } "be 0 if upload retry succeeds" in new BasicSetup { setLocalFile("index.html") uploadFailsAndThenSucceeds(howManyFailures = 1) push must equalTo(0) } "be 1 if delete retry fails" in new BasicSetup { setLocalFile("index.html") uploadFailsAndThenSucceeds(howManyFailures = 6) push must equalTo(1) } "be 1 if an object listing fails" in new BasicSetup { setS3Files(S3File("old.html", md5Hex("

old text

"))) objectListingFailsAndThenSucceeds(howManyFailures = 6) push must equalTo(1) } } "s3_website.yml file" should { "never be uploaded" in new BasicSetup { setLocalFile("s3_website.yml") push noUploadsOccurred must beTrue } } ".env file" should { // The .env file is the https://github.com/bkeepers/dotenv file "never be uploaded" in new BasicSetup { setLocalFile(".env") push noUploadsOccurred must beTrue } } "exclude_from_upload: string" should { "result in matching files not being uploaded" in new BasicSetup { config = "exclude_from_upload: .DS_.*?" setLocalFile(".DS_Store") push noUploadsOccurred must beTrue } } """ exclude_from_upload: - regex - another_exclusion """ should { "result in matching files not being uploaded" in new BasicSetup { config = """ |exclude_from_upload: | - .DS_.*? | - logs """.stripMargin setLocalFiles(".DS_Store", "logs/test.log") push noUploadsOccurred must beTrue } } "ignore_on_server: value" should { "not delete the S3 objects that match the ignore value" in new BasicSetup { config = "ignore_on_server: logs" setS3Files(S3File("logs/log.txt", "")) push noDeletesOccurred must beTrue } "support non-US-ASCII files" in new BasicSetup { setS3Files(S3File("tags/笔记/test.html", "")) config = "ignore_on_server: tags/笔记/test.html" push noDeletesOccurred must beTrue } } "ignore_on_server: _DELETE_NOTHING_ON_THE_S3_BUCKET_" should { "result in no files being deleted on the S3 bucket" in new BasicSetup { config = s""" |ignore_on_server: $DELETE_NOTHING_MAGIC_WORD """.stripMargin setS3Files(S3File("file.txt", "")) push noDeletesOccurred } } """ ignore_on_server: - regex - another_ignore """ should { "not delete the S3 objects that match the ignore value" in new BasicSetup { config = """ |ignore_on_server: | - .*txt """.stripMargin setS3Files(S3File("logs/log.txt", "")) push noDeletesOccurred must beTrue } "support non-US-ASCII files" in new BasicSetup { setS3Files(S3File("tags/笔记/test.html", "")) config = """ |ignore_on_server: | - tags/笔记/test.html """.stripMargin push noDeletesOccurred must beTrue } } "site in config" should { "let the user deploy a site from a custom location" in new CustomSiteDirectory with EmptySite with MockAWS with DefaultRunMode { config = s"site: $siteDirectory" setLocalFile(".vimrc") new File(siteDirectory, ".vimrc").exists() must beTrue // Sanity check siteDirectory must not equalTo workingDirectory // Sanity check push sentPutObjectRequest.getKey must equalTo(".vimrc") } "not override the --site command-line switch" in new BasicSetup { config = s"site: dir-that-does-not-exist" setLocalFile(".vimrc") // This creates a file in the directory into which the --site CLI arg points push sentPutObjectRequest.getKey must equalTo(".vimrc") } automaticallySupportedSiteGenerators foreach { siteGenerator => "override an automatically detected site" in new CustomSiteDirectory with EmptySite with MockAWS with DefaultRunMode { addContentToAutomaticallyDetectedSite(workingDirectory) config = s"site: $siteDirectory" setLocalFile(".vimrc") // Add content to the custom site directory push sentPutObjectRequest.getKey must equalTo(".vimrc") } def addContentToAutomaticallyDetectedSite(workingDirectory: File) { val automaticallyDetectedSiteDir = new File(workingDirectory, siteGenerator.outputDirectory) automaticallyDetectedSiteDir.mkdirs() write(new File(automaticallyDetectedSiteDir, ".bashrc"), "echo hello") } } } "max-age in config" can { "be applied to all files" in new BasicSetup { config = "max_age: 60" setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=60") } "be applied to files that match the glob" in new BasicSetup { config = """ |max_age: | "*.html": 90 """.stripMargin setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90") } "be applied to directories that match the glob" in new BasicSetup { config = """ |max_age: | "assets/**/*.js": 90 """.stripMargin setLocalFile("assets/lib/jquery.js") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90") } "not be applied if the glob doesn't match" in new BasicSetup { config = """ |max_age: | "*.js": 90 """.stripMargin setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must beNull } "be used to disable caching" in new BasicSetup { config = "max_age: 0" setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("no-cache; max-age=0") } "support non-US-ASCII directory names" in new BasicSetup { config = """ |max_age: | "*": 21600 """.stripMargin setLocalFile("tags/笔记/index.html") push must equalTo(0) } } "max-age in config" should { "respect the more specific glob" in new BasicSetup { config = """ |max_age: | "assets/*": 150 | "assets/*.gif": 86400 """.stripMargin setLocalFiles("assets/jquery.js", "assets/picture.gif") push sentPutObjectRequests.find(_.getKey == "assets/jquery.js").get.getMetadata.getCacheControl must equalTo("max-age=150") sentPutObjectRequests.find(_.getKey == "assets/picture.gif").get.getMetadata.getCacheControl must equalTo("max-age=86400") } } "s3_reduced_redundancy: true in config" should { "result in uploads being marked with reduced redundancy" in new BasicSetup { config = "s3_reduced_redundancy: true" setLocalFile("file.exe") push sentPutObjectRequest.getStorageClass must equalTo("REDUCED_REDUNDANCY") } } "s3_reduced_redundancy: false in config" should { "result in uploads being marked with the default storage class" in new BasicSetup { config = "s3_reduced_redundancy: false" setLocalFile("file.exe") push sentPutObjectRequest.getStorageClass must beNull } } "redirect in config" should { "result in a redirect instruction that is sent to AWS" in new BasicSetup { config = """ |redirects: | index.php: /index.html """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("/index.html") } "add slash to the redirect target" in new BasicSetup { config = """ |redirects: | index.php: index.html """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("/index.html") } "support external redirects" in new BasicSetup { config = """ |redirects: | index.php: http://www.youtube.com/watch?v=dQw4w9WgXcQ """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("http://www.youtube.com/watch?v=dQw4w9WgXcQ") } "support external redirects that point to an HTTPS target" in new BasicSetup { config = """ |redirects: | index.php: https://www.youtube.com/watch?v=dQw4w9WgXcQ """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("https://www.youtube.com/watch?v=dQw4w9WgXcQ") } "result in max-age=0 Cache-Control header on the object" in new BasicSetup { config = """ |redirects: | index.php: /index.html """.stripMargin push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=0, no-cache") } } "redirect in config and an object on the S3 bucket" should { "not result in the S3 object being deleted" in new BasicSetup { config = """ |redirects: | index.php: /index.html """.stripMargin setLocalFile("index.php") setS3Files(S3File("index.php", "md5")) push noDeletesOccurred must beTrue } } "dotfiles" should { "be included in the pushed files" in new BasicSetup { setLocalFile(".vimrc") push sentPutObjectRequest.getKey must equalTo(".vimrc") } } "content type inference" should { "add charset=utf-8 to all html documents" in new BasicSetup { setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8") } "add charset=utf-8 to all text documents" in new BasicSetup { setLocalFile("index.txt") push sentPutObjectRequest.getMetadata.getContentType must equalTo("text/plain; charset=utf-8") } "add charset=utf-8 to all json documents" in new BasicSetup { setLocalFile("data.json") push sentPutObjectRequest.getMetadata.getContentType must equalTo("application/json; charset=utf-8") } "resolve the content type from file contents" in new BasicSetup { setLocalFileWithContent(("index", "

hi

")) push sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8") } } "ERB in config file" should { "be evaluated" in new BasicSetup { config = """ |redirects: |<%= ('a'..'f').to_a.map do |t| ' '+t+ ': /'+t+'.html' end.join('\n')%> """.stripMargin push sentPutObjectRequests.length must equalTo(6) sentPutObjectRequests.forall(_.getRedirectLocation != null) must beTrue } } "push --force" should { "push all the files whether they have changed or not" in new ForcePush { setLocalFileWithContent(("index.html", "

hi

")) setS3Files(S3File("index.html", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push sentPutObjectRequest.getKey must equalTo("index.html") } } "dry run" should { "not push updates" in new DryRun { setLocalFileWithContent(("index.html", "
new
")) setS3Files(S3File("index.html", md5Hex("
old
"))) push noUploadsOccurred must beTrue } "not push redirects" in new DryRun { config = """ |redirects: | index.php: /index.html """.stripMargin push noUploadsOccurred must beTrue } "not push deletes" in new DryRun { setS3Files(S3File("index.html", md5Hex("
old
"))) push noUploadsOccurred must beTrue } "not push new files" in new DryRun { setLocalFile("index.html") push noUploadsOccurred must beTrue } "not invalidate files" in new DryRun { config = "cloudfront_invalidation_id: AABBCC" setS3Files(S3File("index.html", md5Hex("
old
"))) push noInvalidationsOccurred must beTrue } } "Jekyll site" should { "be detected automatically" in new JekyllSite with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") push sentPutObjectRequests.length must equalTo(1) } } "Nanoc site" should { "be detected automatically" in new NanocSite with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") push sentPutObjectRequests.length must equalTo(1) } } trait BasicSetup extends SiteLocationFromCliArg with EmptySite with MockAWS with DefaultRunMode trait ForcePush extends SiteLocationFromCliArg with EmptySite with MockAWS with ForcePushMode trait DryRun extends SiteLocationFromCliArg with EmptySite with MockAWS with DryRunMode trait DefaultRunMode { implicit def pushOptions: PushOptions = new PushOptions { def dryRun = false def force = false } } trait DryRunMode { implicit def pushOptions: PushOptions = new PushOptions { def dryRun = true def force = false } } trait ForcePushMode { implicit def pushOptions: PushOptions = new PushOptions { def dryRun = false def force = true } } trait MockAWS extends MockS3 with MockCloudFront with Scope trait MockCloudFront extends MockAWSHelper { val amazonCloudFrontClient = mock(classOf[AmazonCloudFront]) implicit val cfSettings: CloudFrontSetting = CloudFrontSetting( cfClient = _ => amazonCloudFrontClient, retryTimeUnit = MICROSECONDS ) def sentInvalidationRequests: Seq[CreateInvalidationRequest] = { val createInvalidationReq = ArgumentCaptor.forClass(classOf[CreateInvalidationRequest]) verify(amazonCloudFrontClient, Mockito.atLeastOnce()).createInvalidation(createInvalidationReq.capture()) createInvalidationReq.getAllValues } def sentInvalidationRequest = sentInvalidationRequests.ensuring(_.length == 1).head def noInvalidationsOccurred = { verify(amazonCloudFrontClient, Mockito.never()).createInvalidation(Matchers.any(classOf[CreateInvalidationRequest])) true // Mockito is based on exceptions } def invalidationsFailAndThenSucceed(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[CreateInvalidationResult])) .when(amazonCloudFrontClient) .createInvalidation(Matchers.anyObject()) } def setTooManyInvalidationsInProgress(attemptWhenInvalidationSucceeds: Int) { var callCount = 0 doAnswer(new Answer[CreateInvalidationResult] { override def answer(invocation: InvocationOnMock): CreateInvalidationResult = { callCount += 1 if (callCount < attemptWhenInvalidationSucceeds) throw new TooManyInvalidationsInProgressException("just too many, man") else mock(classOf[CreateInvalidationResult]) } }).when(amazonCloudFrontClient).createInvalidation(Matchers.anyObject()) } def setCloudFrontAsInternallyBroken() { when(amazonCloudFrontClient.createInvalidation(Matchers.anyObject())).thenThrow(new AmazonServiceException("CloudFront is down")) } } trait MockS3 extends MockAWSHelper { val amazonS3Client = mock(classOf[AmazonS3]) implicit val s3Settings: S3Setting = S3Setting( s3Client = _ => amazonS3Client, retryTimeUnit = MICROSECONDS ) val s3ObjectListing = new ObjectListing when(amazonS3Client.listObjects(Matchers.any(classOf[ListObjectsRequest]))).thenReturn(s3ObjectListing) def setOutdatedS3Keys(s3Keys: String*) { s3Keys .map(key => S3File(key, md5Hex(Random.nextLong().toString)) // Simulate the situation where the file on S3 is outdated (as compared to the local file) ) .foreach (setS3Files(_)) } def setS3Files(s3Files: S3File*) { s3Files.foreach { s3File => s3ObjectListing.getObjectSummaries.add({ val summary = new S3ObjectSummary summary.setETag(s3File.md5) summary.setKey(s3File.s3Key) summary }) } } def removeAllFilesFromS3() { setS3Files(Nil: _*) // This corresponds to the situation where the S3 bucket is empty } def uploadFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[PutObjectResult])) .when(amazonS3Client) .putObject(Matchers.anyObject()) } def deleteFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[DeleteObjectRequest])) .when(amazonS3Client) .deleteObject(Matchers.anyString(), Matchers.anyString()) } def objectListingFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[ObjectListing])) .when(amazonS3Client) .listObjects(Matchers.any(classOf[ListObjectsRequest])) } def sentPutObjectRequests: Seq[PutObjectRequest] = { val req = ArgumentCaptor.forClass(classOf[PutObjectRequest]) verify(amazonS3Client, Mockito.atLeast(1)).putObject(req.capture()) req.getAllValues } def sentPutObjectRequest = sentPutObjectRequests.ensuring(_.length == 1).head def sentDeletes: Seq[S3Key] = { val deleteKey = ArgumentCaptor.forClass(classOf[S3Key]) verify(amazonS3Client).deleteObject(Matchers.anyString(), deleteKey.capture()) deleteKey.getAllValues } def sentDelete = sentDeletes.ensuring(_.length == 1).head def noDeletesOccurred = { verify(amazonS3Client, never()).deleteObject(Matchers.anyString(), Matchers.anyString()) true // Mockito is based on exceptions } def noUploadsOccurred = { verify(amazonS3Client, never()).putObject(Matchers.any(classOf[PutObjectRequest])) true // Mockito is based on exceptions } type S3Key = String } trait MockAWSHelper { def temporaryFailure[T](clazz: Class[T])(implicit callCount: AtomicInteger, howManyFailures: Int) = new Answer[T] { def answer(invocation: InvocationOnMock) = { callCount.incrementAndGet() if (callCount.get() <= howManyFailures) throw new AmazonServiceException("AWS is temporarily down") else mock(clazz) } } } trait Directories extends BeforeAfter { def randomDir() = new File(FileUtils.getTempDirectory, "s3_website_dir" + Random.nextLong()) implicit final val workingDirectory: File = randomDir() implicit def yamlConfig: S3_website_yml = S3_website_yml(new File(workingDirectory, "s3_website.yml")) val siteDirectory: File val configDirectory: File = workingDirectory // Represents the --config-dir=X option def before { workingDirectory :: siteDirectory :: configDirectory :: Nil foreach forceMkdir } def after { (workingDirectory :: siteDirectory :: configDirectory :: Nil) foreach { dir => if (dir.exists) forceDelete(dir) } } } trait SiteLocationFromCliArg extends Directories { val siteDirectory = workingDirectory val siteDirFromCLIArg = true } trait JekyllSite extends Directories { val siteDirectory = new File(workingDirectory, "_site") val siteDirFromCLIArg = false } trait NanocSite extends Directories { val siteDirectory = new File(workingDirectory, "public/output") val siteDirFromCLIArg = false } trait CustomSiteDirectory extends Directories { val siteDirectory = randomDir() val siteDirFromCLIArg = false } trait EmptySite extends Directories { val siteDirFromCLIArg: Boolean type LocalFileWithContent = (String, String) def setLocalFile(fileName: String) = setLocalFileWithContent((fileName, "")) def setLocalFiles(fileNames: String*) = fileNames foreach setLocalFile def setLocalFilesWithContent(fileNamesAndContent: LocalFileWithContent*) = fileNamesAndContent foreach setLocalFileWithContent def setLocalFileWithContent(fileNameAndContent: LocalFileWithContent) = { val file = new File(siteDirectory, fileNameAndContent._1) forceMkdir(file.getParentFile) file.createNewFile() write(file, fileNameAndContent._2) } var config = "" val baseConfig = """ |s3_id: foo |s3_secret: bar |s3_bucket: bucket """.stripMargin implicit def configString: ConfigString = ConfigString( s""" |$baseConfig |$config """.stripMargin ) def pushOptions: PushOptions implicit def cliArgs: CliArgs = new CliArgs { def verbose = true def dryRun = pushOptions.dryRun def site = if (siteDirFromCLIArg) siteDirectory.getAbsolutePath else null def configDir = configDirectory.getAbsolutePath def force = pushOptions.force } } def push(implicit emptyYamlConfig: S3_website_yml, configString: ConfigString, cliArgs: CliArgs, s3Settings: S3Setting, cloudFrontSettings: CloudFrontSetting, workingDirectory: File) = { write(emptyYamlConfig.file, configString.yaml) // Write the yaml config lazily, so that the tests can override the default yaml config Push.push } case class ConfigString(yaml: String) }