feat(android): import private Shopee probe tasks

This commit is contained in:
QiuSW
2026-07-25 19:06:28 +08:00
parent d57c4ae2d4
commit 1c1c158149
25 changed files with 1034 additions and 34 deletions
+16
View File
@@ -53,9 +53,25 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
val generatedProbeAssets = layout.buildDirectory.dir("generated/probeFixtures")
sourceSets.getByName("debug").assets.srcDir(generatedProbeAssets)
val configuredProbeFixtures = providers.gradleProperty("probeFixturesDir")
val syncProbeFixtures = tasks.register<Sync>("syncProbeFixtures") {
if (configuredProbeFixtures.isPresent) {
from(rootProject.file(configuredProbeFixtures.get()))
}
into(generatedProbeAssets.map { it.dir("probe-fixtures") })
}
tasks.matching { it.name == "mergeDebugAssets" }.configureEach {
dependsOn(syncProbeFixtures)
}
}
dependencies {
implementation(project(":task-contract"))
// AndroidX Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1")
@@ -0,0 +1,35 @@
package com.roubao.autopilot.task
import android.content.Context
import com.roubao.task.ProbeTask
import com.roubao.task.ProbeTaskJson
import com.roubao.task.TaskSource
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicInteger
class FixtureTaskSource(
context: Context,
private val taskDocumentAsset: String = DEFAULT_TASK_DOCUMENT_ASSET
) : TaskSource {
private val assetManager = context.applicationContext.assets
private val nextIndex = AtomicInteger(0)
private val tasks: List<ProbeTask> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
val payload = assetManager.open(taskDocumentAsset).bufferedReader(
StandardCharsets.UTF_8
).use { it.readText() }
ProbeTaskJson.decode(payload)
}
override suspend fun nextTask(): ProbeTask? {
val index = nextIndex.getAndIncrement()
return tasks.getOrNull(index)
}
fun referenceImageAsset(task: ProbeTask): String =
"$FIXTURE_ASSET_ROOT/${task.referenceImage.relativePath}"
companion object {
private const val FIXTURE_ASSET_ROOT = "probe-fixtures"
private const val DEFAULT_TASK_DOCUMENT_ASSET = "$FIXTURE_ASSET_ROOT/tasks.json"
}
}
@@ -0,0 +1,27 @@
package com.roubao.autopilot.workflow
import com.roubao.task.ProbeTask
import com.roubao.task.TaskSource
sealed interface ProbeWorkflowResult {
data object NoTask : ProbeWorkflowResult
data class Executed(
val probeId: String,
val report: WorkflowReport
) : ProbeWorkflowResult
}
class ProbeWorkflowCoordinator(
private val taskSource: TaskSource,
private val workflowRunner: WorkflowRunner,
private val stepsForTask: (ProbeTask) -> List<WorkflowStep>
) {
suspend fun runNext(): ProbeWorkflowResult {
val task = taskSource.nextTask() ?: return ProbeWorkflowResult.NoTask
return ProbeWorkflowResult.Executed(
probeId = task.probeId,
report = workflowRunner.run(stepsForTask(task))
)
}
}
@@ -0,0 +1,63 @@
package com.roubao.autopilot.workflow
import com.roubao.task.ProbeReferenceImage
import com.roubao.task.ProbeTask
import com.roubao.task.TaskSource
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class ProbeWorkflowCoordinatorTest {
@Test
fun `workflow consumes task source without knowing fixture implementation`() = runTest {
val expectedTask = probeTask()
val source = TaskSource { expectedTask }
val runner = WorkflowRunner { AutomationResult.Success }
val coordinator = ProbeWorkflowCoordinator(
taskSource = source,
workflowRunner = runner,
stepsForTask = { task ->
assertEquals(expectedTask.title, task.title)
assertEquals(expectedTask.sku, task.sku)
assertEquals(expectedTask.quantity, task.quantity)
listOf(WorkflowStep("prepare_search", timeoutMillis = 1_000))
}
)
val result = coordinator.runNext() as ProbeWorkflowResult.Executed
assertEquals(expectedTask.probeId, result.probeId)
assertEquals(WorkflowState.SUCCEEDED, result.report.state)
}
@Test
fun `empty task source does not start workflow`() = runTest {
var automationCalls = 0
val coordinator = ProbeWorkflowCoordinator(
taskSource = TaskSource { null },
workflowRunner = WorkflowRunner {
automationCalls += 1
AutomationResult.Success
},
stepsForTask = { listOf(WorkflowStep("unused", timeoutMillis = 1_000)) }
)
assertEquals(ProbeWorkflowResult.NoTask, coordinator.runNext())
assertEquals(0, automationCalls)
}
private fun probeTask() = ProbeTask(
probeId = "probe_0123456789abcdef",
sourceOrderNo = "ORDER_000001",
sourceStoreName = "Test Store",
title = "Test Product",
sku = "SKU-001",
quantity = 1,
referenceImage = ProbeReferenceImage(
relativePath = "assets/0123456789abcdef.jpg",
mediaType = "image/jpeg",
sizeBytes = 123,
sha256 = "a".repeat(64)
)
)
}
+1
View File
@@ -2,4 +2,5 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
id("org.jetbrains.kotlin.jvm") version "1.9.20" apply false
}
+2
View File
@@ -17,3 +17,5 @@ dependencyResolutionManagement {
rootProject.name = "AutoPilot"
include(":app")
include(":task-contract")
include(":tools:shopee-importer")
@@ -0,0 +1,16 @@
plugins {
id("org.jetbrains.kotlin.jvm")
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation("org.json:json:20231013")
testImplementation("junit:junit:4.13.2")
}
tasks.test {
useJUnit()
}
@@ -0,0 +1,56 @@
package com.roubao.task
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
data class ProbeReferenceImage(
val relativePath: String,
val mediaType: String,
val sizeBytes: Long,
val sha256: String
) {
init {
require(isSafeRelativePath(relativePath)) {
"Reference image path must be a safe relative path"
}
require(mediaType == "image/jpeg") { "Only JPEG reference images are supported" }
require(sizeBytes > 0) { "Reference image size must be positive" }
require(SHA256_PATTERN.matches(sha256)) { "Reference image SHA-256 is invalid" }
}
}
data class ProbeTask(
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
val probeId: String,
val sourceOrderNo: String,
val sourceStoreName: String,
val title: String,
val sku: String,
val quantity: Int,
val referenceImage: ProbeReferenceImage
) {
init {
require(schemaVersion == CURRENT_SCHEMA_VERSION) {
"Unsupported ProbeTask schema version"
}
require(probeId.isNotBlank()) { "Probe task id must not be blank" }
require(sourceOrderNo.isNotBlank()) { "Source order number must not be blank" }
require(sourceStoreName.isNotBlank()) { "Source store name must not be blank" }
require(title.isNotBlank()) { "Title must not be blank" }
require(sku.isNotBlank()) { "SKU must not be blank" }
require(quantity > 0) { "Quantity must be positive" }
}
companion object {
const val CURRENT_SCHEMA_VERSION = 1
}
}
private fun isSafeRelativePath(path: String): Boolean {
if (path.isBlank() || path.startsWith("/") || path.startsWith("\\")) {
return false
}
if ('\\' in path) {
return false
}
return path.split('/').none { it.isBlank() || it == "." || it == ".." }
}
@@ -0,0 +1,62 @@
package com.roubao.task
import org.json.JSONArray
import org.json.JSONObject
object ProbeTaskJson {
fun encode(tasks: Collection<ProbeTask>): String {
val root = JSONObject()
root.put("schema_version", ProbeTask.CURRENT_SCHEMA_VERSION)
root.put("tasks", JSONArray().apply {
tasks.forEach { put(it.toJson()) }
})
return root.toString(2) + "\n"
}
fun decode(payload: String): List<ProbeTask> {
val root = JSONObject(payload)
require(root.getInt("schema_version") == ProbeTask.CURRENT_SCHEMA_VERSION) {
"Unsupported ProbeTask document schema version"
}
val tasks = root.getJSONArray("tasks")
return buildList(tasks.length()) {
for (index in 0 until tasks.length()) {
add(tasks.getJSONObject(index).toProbeTask())
}
}
}
private fun ProbeTask.toJson(): JSONObject = JSONObject()
.put("probe_id", probeId)
.put("source_order_no", sourceOrderNo)
.put("source_store_name", sourceStoreName)
.put("title", title)
.put("sku", sku)
.put("quantity", quantity)
.put(
"reference_image",
JSONObject()
.put("relative_path", referenceImage.relativePath)
.put("media_type", referenceImage.mediaType)
.put("size_bytes", referenceImage.sizeBytes)
.put("sha256", referenceImage.sha256)
)
private fun JSONObject.toProbeTask(): ProbeTask {
val image = getJSONObject("reference_image")
return ProbeTask(
probeId = getString("probe_id"),
sourceOrderNo = getString("source_order_no"),
sourceStoreName = getString("source_store_name"),
title = getString("title"),
sku = getString("sku"),
quantity = getInt("quantity"),
referenceImage = ProbeReferenceImage(
relativePath = image.getString("relative_path"),
mediaType = image.getString("media_type"),
sizeBytes = image.getLong("size_bytes"),
sha256 = image.getString("sha256")
)
)
}
}
@@ -0,0 +1,5 @@
package com.roubao.task
fun interface TaskSource {
suspend fun nextTask(): ProbeTask?
}
@@ -0,0 +1,26 @@
package com.roubao.task
import org.junit.Assert.assertEquals
import org.junit.Test
class ProbeTaskJsonTest {
@Test
fun `task document round trips without changing authoritative fields`() {
val task = ProbeTask(
probeId = "probe_0123456789abcdef",
sourceOrderNo = "ORDER_000001",
sourceStoreName = "Test Store",
title = "Test Product",
sku = "SKU-BLACK",
quantity = 2,
referenceImage = ProbeReferenceImage(
relativePath = "assets/0123456789abcdef.jpg",
mediaType = "image/jpeg",
sizeBytes = 123,
sha256 = "a".repeat(64)
)
)
assertEquals(listOf(task), ProbeTaskJson.decode(ProbeTaskJson.encode(listOf(task))))
}
}
@@ -0,0 +1,25 @@
plugins {
id("org.jetbrains.kotlin.jvm")
application
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation(project(":task-contract"))
testImplementation("junit:junit:4.13.2")
}
application {
mainClass.set("com.roubao.tools.shopee.MainKt")
}
tasks.named<JavaExec>("run") {
workingDir(rootProject.projectDir)
}
tasks.test {
useJUnit()
}
@@ -0,0 +1,42 @@
package com.roubao.tools.shopee
import java.io.PrintStream
import java.nio.file.Path
object ImporterCli {
fun run(args: Array<String>, out: PrintStream, err: PrintStream): Int =
try {
val arguments = parseArguments(args)
val summary = ShopeeFixtureImporter().importDirectory(
inputDirectory = Path.of(arguments.getValue("--input")),
outputDirectory = Path.of(arguments.getValue("--output"))
)
out.println("Imported ${summary.taskCount} task(s).")
0
} catch (error: FixtureImportException) {
err.println("Import failed [${error.code.name}].")
2
} catch (_: Exception) {
err.println("Import failed [${ImportErrorCode.IO_ERROR.name}].")
2
}
private fun parseArguments(args: Array<String>): Map<String, String> {
if (args.size != 4) {
throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS)
}
val parsed = linkedMapOf<String, String>()
args.toList().chunked(2).forEach { pair ->
val key = pair[0]
val value = pair[1]
if (key !in setOf("--input", "--output") || value.isBlank() || key in parsed) {
throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS)
}
parsed[key] = value
}
if (parsed.keys != setOf("--input", "--output")) {
throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS)
}
return parsed
}
}
@@ -0,0 +1,7 @@
package com.roubao.tools.shopee
import kotlin.system.exitProcess
fun main(args: Array<String>) {
exitProcess(ImporterCli.run(args, System.out, System.err))
}
@@ -0,0 +1,297 @@
package com.roubao.tools.shopee
import com.roubao.task.ProbeReferenceImage
import com.roubao.task.ProbeTask
import com.roubao.task.ProbeTaskJson
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.Comparator
import javax.imageio.ImageIO
enum class ImportErrorCode {
INVALID_ARGUMENTS,
INPUT_NOT_DIRECTORY,
NO_ORDERS,
DUPLICATE_ORDER,
TEXT_TOO_LARGE,
INVALID_UTF8,
UTF8_BOM_NOT_ALLOWED,
INVALID_TEXT_FORMAT,
MISSING_FIELD,
INVALID_QUANTITY,
MISSING_IMAGE,
MULTIPLE_IMAGES,
UNSUPPORTED_IMAGE_TYPE,
IMAGE_TOO_LARGE,
INVALID_IMAGE,
OUTPUT_NOT_OWNED,
OUTPUT_EQUALS_INPUT,
IO_ERROR
}
class FixtureImportException(
val code: ImportErrorCode
) : RuntimeException(code.name)
data class ImportSummary(
val taskCount: Int
)
class ShopeeFixtureImporter {
fun importDirectory(inputDirectory: Path, outputDirectory: Path): ImportSummary =
try {
importChecked(
inputDirectory.toAbsolutePath().normalize(),
outputDirectory.toAbsolutePath().normalize()
)
} catch (error: FixtureImportException) {
throw error
} catch (_: Exception) {
throw FixtureImportException(ImportErrorCode.IO_ERROR)
}
private fun importChecked(inputDirectory: Path, outputDirectory: Path): ImportSummary {
if (!Files.isDirectory(inputDirectory)) {
throw FixtureImportException(ImportErrorCode.INPUT_NOT_DIRECTORY)
}
if (inputDirectory == outputDirectory) {
throw FixtureImportException(ImportErrorCode.OUTPUT_EQUALS_INPUT)
}
val textFiles = listRegularFiles(inputDirectory)
.filter { extension(it) == "txt" }
.filter { ORDER_NAME_PATTERN.matches(stem(it)) }
.sortedBy { it.fileName.toString().lowercase() }
if (textFiles.isEmpty()) {
throw FixtureImportException(ImportErrorCode.NO_ORDERS)
}
validateUniqueOrderNumbers(textFiles.map(::stem))
val inputFiles = listRegularFiles(inputDirectory)
val imported = textFiles.map { textFile ->
parseCandidate(textFile, inputFiles)
}
prepareOwnedOutput(outputDirectory)
val assetsDirectory = outputDirectory.resolve(ASSETS_DIRECTORY)
Files.createDirectories(assetsDirectory)
imported.forEach { candidate ->
val target = outputDirectory.resolve(candidate.task.referenceImage.relativePath)
Files.write(target, candidate.imageBytes)
}
Files.writeString(
outputDirectory.resolve(TASK_DOCUMENT),
ProbeTaskJson.encode(imported.map { it.task }),
StandardCharsets.UTF_8
)
return ImportSummary(taskCount = imported.size)
}
private fun parseCandidate(textFile: Path, inputFiles: List<Path>): ImportedCandidate {
val orderNumber = stem(textFile)
val parsed = parseOrderText(textFile)
val imageFile = findImage(orderNumber, inputFiles)
val imageBytes = readAndValidateJpeg(imageFile)
val anonymousId = sha256Hex(
"probe-task:$orderNumber".toByteArray(StandardCharsets.UTF_8)
).take(16)
return ImportedCandidate(
task = ProbeTask(
probeId = "probe_$anonymousId",
sourceOrderNo = orderNumber,
sourceStoreName = parsed.storeName,
title = parsed.title,
sku = parsed.sku,
quantity = parsed.quantity,
referenceImage = ProbeReferenceImage(
relativePath = "$ASSETS_DIRECTORY/$anonymousId.jpg",
mediaType = "image/jpeg",
sizeBytes = imageBytes.size.toLong(),
sha256 = sha256Hex(imageBytes)
)
),
imageBytes = imageBytes
)
}
private fun parseOrderText(path: Path): ParsedOrderText {
val size = Files.size(path)
if (size <= 0 || size > MAX_TEXT_BYTES) {
throw FixtureImportException(ImportErrorCode.TEXT_TOO_LARGE)
}
val bytes = Files.readAllBytes(path)
val decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
val text = try {
decoder.decode(ByteBuffer.wrap(bytes)).toString()
} catch (_: CharacterCodingException) {
throw FixtureImportException(ImportErrorCode.INVALID_UTF8)
}
if (text.startsWith('\uFEFF')) {
throw FixtureImportException(ImportErrorCode.UTF8_BOM_NOT_ALLOWED)
}
val lines = text
.replace("\r\n", "\n")
.replace('\r', '\n')
.split('\n')
.toMutableList()
while (lines.lastOrNull()?.isEmpty() == true) {
lines.removeLast()
}
if (lines.size != EXPECTED_LABELS.size) {
throw FixtureImportException(ImportErrorCode.INVALID_TEXT_FORMAT)
}
val values = EXPECTED_LABELS.mapIndexed { index, label ->
parseLabeledValue(lines[index], label)
}
val quantity = values[3].toIntOrNull()
?.takeIf { it > 0 }
?: throw FixtureImportException(ImportErrorCode.INVALID_QUANTITY)
return ParsedOrderText(
storeName = values[0],
title = values[1],
sku = values[2],
quantity = quantity
)
}
private fun parseLabeledValue(line: String, expectedLabel: String): String {
val prefix = "$expectedLabel:"
if (!line.startsWith(prefix)) {
throw FixtureImportException(ImportErrorCode.INVALID_TEXT_FORMAT)
}
val value = line.substring(prefix.length)
if (value.isBlank()) {
throw FixtureImportException(ImportErrorCode.MISSING_FIELD)
}
return value
}
private fun findImage(orderNumber: String, inputFiles: List<Path>): Path {
val matchingImages = inputFiles.filter { file ->
stem(file).equals(orderNumber, ignoreCase = true) &&
extension(file) in RECOGNIZED_IMAGE_EXTENSIONS
}
if (matchingImages.isEmpty()) {
throw FixtureImportException(ImportErrorCode.MISSING_IMAGE)
}
if (matchingImages.any { extension(it) !in SUPPORTED_IMAGE_EXTENSIONS }) {
throw FixtureImportException(ImportErrorCode.UNSUPPORTED_IMAGE_TYPE)
}
if (matchingImages.size != 1) {
throw FixtureImportException(ImportErrorCode.MULTIPLE_IMAGES)
}
return matchingImages.single()
}
private fun readAndValidateJpeg(path: Path): ByteArray {
val size = Files.size(path)
if (size <= 0 || size > MAX_IMAGE_BYTES) {
throw FixtureImportException(ImportErrorCode.IMAGE_TOO_LARGE)
}
val bytes = Files.readAllBytes(path)
val hasJpegMagic = bytes.size >= 3 &&
bytes[0] == 0xff.toByte() &&
bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte()
val image: BufferedImage? = if (hasJpegMagic) {
runCatching {
ImageIO.read(ByteArrayInputStream(bytes))
}.getOrNull()
} else {
null
}
if (image == null || image.width <= 0 || image.height <= 0) {
throw FixtureImportException(ImportErrorCode.INVALID_IMAGE)
}
return bytes
}
private fun prepareOwnedOutput(outputDirectory: Path) {
if (Files.exists(outputDirectory)) {
if (!Files.isDirectory(outputDirectory)) {
throw FixtureImportException(ImportErrorCode.OUTPUT_NOT_OWNED)
}
val hasEntries = Files.list(outputDirectory).use { it.findAny().isPresent }
if (hasEntries && !Files.isRegularFile(outputDirectory.resolve(OUTPUT_MARKER))) {
throw FixtureImportException(ImportErrorCode.OUTPUT_NOT_OWNED)
}
Files.walk(outputDirectory).use { paths ->
paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) }
}
}
Files.createDirectories(outputDirectory)
Files.writeString(
outputDirectory.resolve(OUTPUT_MARKER),
"cmroubao-probe-output-v1\n",
StandardCharsets.US_ASCII
)
}
private fun listRegularFiles(directory: Path): List<Path> =
Files.list(directory).use { files ->
files.filter(Files::isRegularFile).toList()
}
internal fun validateUniqueOrderNumbers(orderNumbers: Collection<String>) {
val unique = HashSet<String>()
orderNumbers.forEach { orderNumber ->
if (!unique.add(orderNumber.lowercase())) {
throw FixtureImportException(ImportErrorCode.DUPLICATE_ORDER)
}
}
}
private fun stem(path: Path): String =
path.fileName.toString().substringBeforeLast('.')
private fun extension(path: Path): String =
path.fileName.toString().substringAfterLast('.', missingDelimiterValue = "").lowercase()
private fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(bytes)
.joinToString(separator = "") { "%02x".format(it) }
private data class ParsedOrderText(
val storeName: String,
val title: String,
val sku: String,
val quantity: Int
)
private data class ImportedCandidate(
val task: ProbeTask,
val imageBytes: ByteArray
)
companion object {
private const val MAX_TEXT_BYTES = 64L * 1024
private const val MAX_IMAGE_BYTES = 20L * 1024 * 1024
private const val ASSETS_DIRECTORY = "assets"
private const val TASK_DOCUMENT = "tasks.json"
private const val OUTPUT_MARKER = ".cmroubao-probe-output"
private val ORDER_NAME_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9_-]{5,63}")
private val EXPECTED_LABELS = listOf("店铺名", "商品标题", "SKU", "数量")
private val SUPPORTED_IMAGE_EXTENSIONS = setOf("jpg", "jpeg")
private val RECOGNIZED_IMAGE_EXTENSIONS =
SUPPORTED_IMAGE_EXTENSIONS + setOf("png", "webp", "gif", "bmp")
}
}
@@ -0,0 +1,241 @@
package com.roubao.tools.shopee
import com.roubao.task.ProbeTaskJson
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import javax.imageio.ImageIO
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class ShopeeFixtureImporterTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `valid pair generates task with exact authoritative fields`() {
val input = newDirectory("valid-input")
val output = newPath("valid-output")
val orderNumber = "ORDER_000001"
writeOrder(
directory = input,
orderNumber = orderNumber,
storeName = "Test Store",
title = "Test Product",
sku = "SKU-BLACK",
quantity = "2"
)
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
val summary = ShopeeFixtureImporter().importDirectory(input, output)
val tasks = ProbeTaskJson.decode(Files.readString(output.resolve("tasks.json")))
assertEquals(1, summary.taskCount)
assertEquals(1, tasks.size)
assertEquals(orderNumber, tasks.single().sourceOrderNo)
assertEquals("Test Store", tasks.single().sourceStoreName)
assertEquals("Test Product", tasks.single().title)
assertEquals("SKU-BLACK", tasks.single().sku)
assertEquals(2, tasks.single().quantity)
assertTrue(Files.isRegularFile(output.resolve(tasks.single().referenceImage.relativePath)))
assertFalse(tasks.single().referenceImage.relativePath.contains(orderNumber))
}
@Test
fun `missing image is rejected`() {
val input = newDirectory("missing-image")
writeOrder(input, "ORDER_000002")
assertImportError(ImportErrorCode.MISSING_IMAGE) {
ShopeeFixtureImporter().importDirectory(input, newPath("missing-image-output"))
}
}
@Test
fun `multiple jpeg images are rejected`() {
val input = newDirectory("multiple-images")
val orderNumber = "ORDER_000003"
writeOrder(input, orderNumber)
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
writeImage(input.resolve("$orderNumber.jpeg"), "jpg")
assertImportError(ImportErrorCode.MULTIPLE_IMAGES) {
ShopeeFixtureImporter().importDirectory(input, newPath("multiple-images-output"))
}
}
@Test
fun `unsupported image type is rejected`() {
val input = newDirectory("unsupported-image")
val orderNumber = "ORDER_000004"
writeOrder(input, orderNumber)
writeImage(input.resolve("$orderNumber.png"), "png")
assertImportError(ImportErrorCode.UNSUPPORTED_IMAGE_TYPE) {
ShopeeFixtureImporter().importDirectory(input, newPath("unsupported-image-output"))
}
}
@Test
fun `invalid utf8 is rejected`() {
val input = newDirectory("invalid-utf8")
val orderNumber = "ORDER_000005"
Files.write(input.resolve("$orderNumber.txt"), byteArrayOf(0xc3.toByte(), 0x28))
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
assertImportError(ImportErrorCode.INVALID_UTF8) {
ShopeeFixtureImporter().importDirectory(input, newPath("invalid-utf8-output"))
}
}
@Test
fun `missing required field is rejected`() {
val input = newDirectory("missing-field")
val orderNumber = "ORDER_000006"
writeOrder(input, orderNumber, sku = "")
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
assertImportError(ImportErrorCode.MISSING_FIELD) {
ShopeeFixtureImporter().importDirectory(input, newPath("missing-field-output"))
}
}
@Test
fun `unknown four line label is rejected`() {
val input = newDirectory("unknown-label")
val orderNumber = "ORDER_000011"
Files.writeString(
input.resolve("$orderNumber.txt"),
"""
店铺名:Test Store
商品标题:Test Product
规格:SKU-001
数量:1
""".trimIndent() + "\n",
StandardCharsets.UTF_8
)
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
assertImportError(ImportErrorCode.INVALID_TEXT_FORMAT) {
ShopeeFixtureImporter().importDirectory(input, newPath("unknown-label-output"))
}
}
@Test
fun `non positive quantity is rejected`() {
val input = newDirectory("invalid-quantity")
val orderNumber = "ORDER_000007"
writeOrder(input, orderNumber, quantity = "0")
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
assertImportError(ImportErrorCode.INVALID_QUANTITY) {
ShopeeFixtureImporter().importDirectory(input, newPath("invalid-quantity-output"))
}
}
@Test
fun `corrupt jpeg is rejected`() {
val input = newDirectory("corrupt-image")
val orderNumber = "ORDER_000008"
writeOrder(input, orderNumber)
Files.write(
input.resolve("$orderNumber.jpg"),
byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte(), 0x00)
)
assertImportError(ImportErrorCode.INVALID_IMAGE) {
ShopeeFixtureImporter().importDirectory(input, newPath("corrupt-image-output"))
}
}
@Test
fun `duplicate order numbers are case insensitive`() {
assertImportError(ImportErrorCode.DUPLICATE_ORDER) {
ShopeeFixtureImporter().validateUniqueOrderNumbers(
listOf("ORDER_000009", "order_000009")
)
}
}
@Test
fun `cli output does not disclose private identifiers or source path`() {
val input = newDirectory("private-source")
val output = newPath("private-output")
val orderNumber = "ORDER_000010"
val storeName = "Private Test Store"
writeOrder(input, orderNumber, storeName = storeName)
writeImage(input.resolve("$orderNumber.jpg"), "jpg")
val stdout = ByteArrayOutputStream()
val stderr = ByteArrayOutputStream()
val exitCode = ImporterCli.run(
arrayOf(
"--input",
input.toString(),
"--output",
output.toString()
),
PrintStream(stdout, true, StandardCharsets.UTF_8),
PrintStream(stderr, true, StandardCharsets.UTF_8)
)
val combinedOutput = stdout.toString(StandardCharsets.UTF_8) +
stderr.toString(StandardCharsets.UTF_8)
assertEquals(0, exitCode)
assertTrue(combinedOutput.contains("Imported 1 task(s)."))
assertFalse(combinedOutput.contains(orderNumber))
assertFalse(combinedOutput.contains(storeName))
assertFalse(combinedOutput.contains(input.toAbsolutePath().toString()))
}
private fun writeOrder(
directory: Path,
orderNumber: String,
storeName: String = "Test Store",
title: String = "Test Product",
sku: String = "SKU-001",
quantity: String = "1"
) {
Files.writeString(
directory.resolve("$orderNumber.txt"),
"""
店铺名:$storeName
商品标题:$title
SKU:$sku
数量:$quantity
""".trimIndent() + "\n",
StandardCharsets.UTF_8
)
}
private fun writeImage(path: Path, format: String) {
val image = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB)
image.setRGB(0, 0, 0x336699)
assertTrue(ImageIO.write(image, format, path.toFile()))
}
private fun newDirectory(name: String): Path =
temporaryFolder.newFolder(name).toPath()
private fun newPath(name: String): Path =
temporaryFolder.root.toPath().resolve(name)
private fun assertImportError(
expected: ImportErrorCode,
block: () -> Unit
) {
try {
block()
throw AssertionError("Expected import error $expected")
} catch (error: FixtureImportException) {
assertEquals(expected, error.code)
}
}
}