17 changed files with 299 additions and 11 deletions
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
package de.codingchallenge.models |
||||
|
||||
import de.codingchallenge.csv.CsvColumnWrites |
||||
|
||||
case class ProductExport(productId: String, name: String, description: String, price:Float, stockSum: Int) |
||||
|
||||
object ProductExport{ |
||||
|
||||
implicit val columnWrites: CsvColumnWrites[ProductExport] = (p: ProductExport) => |
||||
p.productIterator.map(_.toString).toSeq |
||||
|
||||
def apply(a: Article, stockSum: Int): ProductExport = new ProductExport( |
||||
productId = a.productId, |
||||
name = a.name, |
||||
description = a.description, |
||||
price = a.price, |
||||
stockSum = stockSum |
||||
) |
||||
} |
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
package de.codingchallenge.repositories |
||||
|
||||
import akka.NotUsed |
||||
import akka.actor.ActorSystem |
||||
import akka.http.scaladsl.Http |
||||
import akka.http.scaladsl.model.HttpEntity.Chunked |
||||
import akka.http.scaladsl.model._ |
||||
import akka.http.scaladsl.server.ContentNegotiator.Alternative.ContentType |
||||
import akka.stream.scaladsl.{Flow, JavaFlowSupport, Source} |
||||
import akka.util.ByteString |
||||
import de.codingchallenge.models.ProductExport |
||||
import de.codingchallenge.csv.CsvOps._ |
||||
|
||||
import scala.concurrent.Future |
||||
|
||||
class ProductExportRepository(actorSystem: ActorSystem) { |
||||
|
||||
// val headerLine = "produktId|name|beschreibung|preis|summeBestand" |
||||
|
||||
val baseUrl = "http://localhost:8080" |
||||
|
||||
def add(p: Source[ProductExport, _], articlesSize: Int): Future[HttpResponse] = { |
||||
|
||||
val entity = Chunked.fromData( |
||||
ContentTypes.`text/csv(UTF-8)`, |
||||
p.via(csvFlow)) |
||||
|
||||
Http()(actorSystem) |
||||
.singleRequest( |
||||
HttpRequest( |
||||
method = HttpMethods.PUT, |
||||
uri = s"$baseUrl/products/$articlesSize", |
||||
entity = entity)) |
||||
} |
||||
|
||||
private val csvFlow: Flow[ProductExport, ByteString, Any] = |
||||
Flow.fromFunction(p => ByteString(p.toCsvLine + "\n")) |
||||
} |
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
package de.codingchallenge.services |
||||
|
||||
import akka.NotUsed |
||||
import akka.http.scaladsl.model.HttpResponse |
||||
import akka.stream.Materializer |
||||
import akka.stream.scaladsl.{Sink, Source} |
||||
import com.typesafe.scalalogging.LazyLogging |
||||
import de.codingchallenge.models.{Article, ProductExport} |
||||
import de.codingchallenge.repositories.{ArticleRepository, ProductExportRepository} |
||||
|
||||
import scala.concurrent.Future |
||||
|
||||
class ArticleExportService( |
||||
articleRepository: ArticleRepository, |
||||
productExportRepository: ProductExportRepository, |
||||
mat: Materializer |
||||
) extends LazyLogging { |
||||
implicit val m: Materializer = mat |
||||
|
||||
val productsSize: Int = 100 |
||||
|
||||
def exportArticles(): Future[HttpResponse] = productExportRepository.add(Source.fromGraph( |
||||
articleRepository |
||||
.getArticles(productsSize) |
||||
.filter(_.stock > 0) |
||||
.groupBy(1, _.productId) |
||||
.map(a => a -> a.stock) |
||||
.reduce[(Article, Int)] { |
||||
case ((a1, c1), (a2, c2)) if a1.price < a2.price => (a1, c1 + c2) |
||||
case ((a1, c1), (a2, c2)) if a1.price > a2.price => (a2, c1 + c2) |
||||
case ((a1, c1), (_, c2)) => (a1, c1 + c2) |
||||
} |
||||
.mergeSubstreams |
||||
.map { case (article, stockSum) => |
||||
logger.debug(s"Reduced to article: $article and stockSum: $stockSum") |
||||
ProductExport(article, stockSum) } |
||||
), productsSize) |
||||
|
||||
} |
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
package de.codingchallenge |
||||
|
||||
import akka.actor.ActorSystem |
||||
import akka.stream.ActorMaterializer |
||||
import org.scalatest.BeforeAndAfterAll |
||||
|
||||
trait AkkaSpec extends BeforeAndAfterAll{ self: BaseSpec => |
||||
|
||||
implicit val system = ActorSystem("test") |
||||
|
||||
implicit val dispatcher = system.dispatcher |
||||
|
||||
implicit val materializer = ActorMaterializer() |
||||
|
||||
override protected def afterAll(): Unit = { |
||||
super.afterAll() |
||||
system.terminate() |
||||
} |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
package de.codingchallenge |
||||
|
||||
import org.scalatest.mockito.MockitoSugar |
||||
import org.scalatest.{MustMatchers, WordSpec} |
||||
|
||||
trait BaseSpec extends WordSpec with MustMatchers with MockitoSugar |
||||
|
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
package de.codingchallenge.fixtures |
||||
|
||||
import de.codingchallenge.models.Article |
||||
|
||||
trait ArticleFixture { |
||||
|
||||
val articleUnavailable = Article( |
||||
id = "id", |
||||
productId = "productId", |
||||
name = "name", |
||||
description = "desc", |
||||
price = 1, |
||||
stock = 0) |
||||
|
||||
val cheapestArticle = Article( |
||||
id = "cheapest", |
||||
productId = "productId", |
||||
name = "cheapestArticle", |
||||
description = "cheapestArticleDesc", |
||||
price = 0, |
||||
stock = 1) |
||||
|
||||
val anotherArticle = articleUnavailable.copy(price = 1, stock = 5) |
||||
} |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
package de.codingchallenge.fixtures |
||||
|
||||
import de.codingchallenge.models.{Article, ProductExport} |
||||
|
||||
trait ProductExportFixture { |
||||
|
||||
val productExport = ProductExport( |
||||
productId = "productId", |
||||
name = "cheapestArticle", |
||||
description = "cheapestArticleDesc", |
||||
price = 0, |
||||
stockSum = 6 |
||||
) |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
package de.codingchallenge.models |
||||
|
||||
import de.codingchallenge.BaseSpec |
||||
import de.codingchallenge.csv.CsvOps._ |
||||
|
||||
class ArticleSpec extends BaseSpec { |
||||
|
||||
"The Article" must { |
||||
"parse from sample" in { |
||||
val sampleCsv = "A-UhnpVjCE|P-NhImbQSB|CKVTFO LCCOR TFIAZTP|lxqjlivf dppzKc|79.54|0" |
||||
sampleCsv.csvToOptOf[Article].get mustBe Article( |
||||
"A-UhnpVjCE", |
||||
"P-NhImbQSB", |
||||
"CKVTFO LCCOR TFIAZTP", |
||||
"lxqjlivf dppzKc", |
||||
79.54f, |
||||
0) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
package de.codingchallenge.repositories |
||||
|
||||
import akka.stream.scaladsl.Sink |
||||
import de.codingchallenge.models.Article |
||||
import de.codingchallenge.{AkkaSpec, BaseSpec} |
||||
import org.scalatest.{BeforeAndAfterAll, MustMatchers, WordSpec} |
||||
|
||||
import scala.concurrent.duration._ |
||||
import scala.concurrent.Await |
||||
|
||||
class ArticleRepositorySpec extends BaseSpec with AkkaSpec { |
||||
|
||||
"The ArticleRepository" must { |
||||
"parse articles as expected" in { |
||||
val repo = new ArticleRepository(system) |
||||
Await.result(repo.getArticles(1).runWith(Sink.head), 1.second) mustBe a[Article] |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
package de.codingchallenge.services |
||||
|
||||
import akka.http.scaladsl.model.HttpResponse |
||||
import akka.stream.scaladsl.{Sink, Source} |
||||
import de.codingchallenge.fixtures.{ArticleFixture, ProductExportFixture} |
||||
import de.codingchallenge.models.ProductExport |
||||
import de.codingchallenge.repositories.{ArticleRepository, ProductExportRepository} |
||||
import de.codingchallenge.{AkkaSpec, BaseSpec} |
||||
import org.mockito.ArgumentCaptor |
||||
|
||||
import scala.concurrent.duration._ |
||||
import scala.concurrent.{Await, Future} |
||||
import org.mockito.Mockito._ |
||||
import org.mockito.ArgumentMatchers._ |
||||
import org.mockito.internal.verification.argumentmatching.ArgumentMatchingTool |
||||
|
||||
class ArticleExportServiceSpec extends BaseSpec with AkkaSpec{ |
||||
|
||||
trait TestSetup extends ProductExportFixture with ArticleFixture{ |
||||
val articleRepositoryMock = mock[ArticleRepository] |
||||
val productExportRepositoryMock = mock[ProductExportRepository] |
||||
val service = new ArticleExportService(articleRepositoryMock, productExportRepositoryMock, materializer) |
||||
} |
||||
|
||||
"The ArticleExportService" must { |
||||
"pass the expected source to the product export repository" in new TestSetup{ |
||||
doReturn(Source(List(articleUnavailable, cheapestArticle, anotherArticle)), Nil: _*) |
||||
.when(articleRepositoryMock) |
||||
.getArticles(100) |
||||
when(productExportRepositoryMock.add(any(), any())) |
||||
.thenReturn(Future.successful(HttpResponse())) |
||||
|
||||
Await.result(service.exportArticles(), 5.second) |
||||
val sourceCaptor: ArgumentCaptor[Source[ProductExport, _]] = ArgumentCaptor.forClass(classOf[Source[ProductExport, _]]) |
||||
verify(productExportRepositoryMock).add(sourceCaptor.capture(), any()) |
||||
Await.result(sourceCaptor.getValue.runWith(Sink.head), 1.second) mustBe productExport |
||||
|
||||
|
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue