feat: 优化打包产物与历史筛选

This commit is contained in:
shenlei 2026-07-20 16:55:17 +09:00
parent de33b3d4b2
commit 44405e589d
6 changed files with 226 additions and 40 deletions

View File

@ -1,5 +1,6 @@
"""任务 API""" """任务 API"""
import os import os
import json
import shutil import shutil
import tempfile import tempfile
import uuid import uuid
@ -164,6 +165,25 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if task.status in ("running", "pending"): if task.status in ("running", "pending"):
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除") raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
# 同一个 App / 版本 / 分支重复打包会覆盖并复用同一个远端文件;
# 只要仍有其他历史记录引用该下载地址,就不能删除远端产物。
has_shared_artifact = bool(
task.oss_url and db.query(Task).filter(
Task.id != task.id,
Task.oss_url == task.oss_url,
).first()
)
if task.oss_url and not has_shared_artifact:
if not task.config_json:
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端文件")
try:
build_config = json.loads(task.config_json)
from .config import load_config
from ..services.distribution import DistributionError, delete_published_artifacts
delete_published_artifacts(build_config, load_config().get("upload", {}))
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
# 删除日志文件 # 删除日志文件
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists(): if log_path.exists():
@ -178,7 +198,10 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
db.delete(task) db.delete(task)
db.commit() db.commit()
return {"message": "记录已删除"} message = "记录已删除"
if task.oss_url:
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
return {"message": message}
@router.get("/{task_id}/log") @router.get("/{task_id}/log")

View File

@ -2,6 +2,7 @@
import json import json
import plistlib import plistlib
import posixpath import posixpath
import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -15,7 +16,12 @@ class DistributionError(Exception):
def _artifact_stem(config: dict) -> str: def _artifact_stem(config: dict) -> str:
version = config.get("VERSION", "0").replace(".", "_") version = config.get("VERSION", "0").replace(".", "_")
return f"{config.get('APPID', 'app')}_{version}" # 历史任务快照没有 SOURCE_BRANCH继续按旧文件名处理确保删除旧记录时能清理到原产物。
if "SOURCE_BRANCH" not in config:
return f"{config.get('APPID', 'app')}_{version}"
branch = re.sub(r"[^A-Za-z0-9._-]+", "_", config.get("SOURCE_BRANCH", "main")).strip("._-")
build_type = "appstore" if config.get("BUILD_TYPE") == "App_Store" else "adhoc"
return f"{config.get('APPID', 'app')}_{version}_{branch or 'main'}_{build_type}"
def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]: def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
@ -70,6 +76,60 @@ def _remote_paths(config: dict) -> tuple[str, str, str, str]:
) )
def _delete_webdav(config: dict, remote_paths: list[str]):
webdav = config.get("webdav", {})
server_url = webdav.get("server_url", "").rstrip("/")
if not server_url or not webdav.get("username"):
raise DistributionError("WebDAV 配置不完整")
base_path = webdav.get("base_path", "/ios-builds").strip("/")
auth = (webdav.get("username", ""), webdav.get("password", ""))
with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client:
for relative_path in remote_paths:
remote_path = posixpath.join(base_path, relative_path)
response = client.delete(f"{server_url}/{remote_path}")
# 404 代表文件已不存在,可视为清理完成。
if response.status_code not in (200, 202, 204, 404):
raise DistributionError(f"WebDAV 删除失败: {relative_path} ({response.status_code})")
def _delete_oss(config: dict, remote_paths: list[str]):
oss = config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required):
raise DistributionError("OSS 配置不完整")
try:
import oss2
except ImportError as exc:
raise DistributionError("未安装 oss2请重新执行 ./deploy.sh build") from exc
bucket = oss2.Bucket(
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"],
oss["bucket_name"],
connect_timeout=30,
)
for relative_path in remote_paths:
result = bucket.delete_object(relative_path)
if result.status // 100 != 2:
raise DistributionError(f"OSS 删除失败: {relative_path}")
def delete_published_artifacts(build_config: dict, upload_config: dict):
"""删除任务对应的远端分发产物。"""
mode = upload_config.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config)
remote_paths = [ipa_remote]
if build_config.get("BUILD_TYPE") != "App_Store":
remote_paths.extend([manifest_remote, html_remote, qr_remote])
deleter = _delete_oss if mode == "oss" else _delete_webdav
deleter(upload_config, remote_paths)
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str): def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
path = "" path = ""
for segment in remote_path.strip("/").split("/")[:-1]: for segment in remote_path.strip("/").split("/")[:-1]:

View File

@ -7,9 +7,9 @@ global.fetch = vi.fn()
window.open = vi.fn() window.open = vi.fn()
const mockTasks = [ const mockTasks = [
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' }, { id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', status: 'completed', created_at: '2024-01-01T10:00:00', config_json: '{"VERSION":"2.196.0"}', has_log: true, dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' },
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败', error_category: 'compilation' }, { id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', config_json: '{"VERSION":"2.195.0"}', error_message: '构建失败', error_category: 'compilation' },
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' }, { id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00', config_json: '{"VERSION":"2.196.0"}' },
] ]
function createMockRouter() { function createMockRouter() {
@ -86,7 +86,7 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[0].setValue('Ad_Hoc') await selects[2].setValue('Ad_Hoc')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
@ -101,13 +101,31 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[1].setValue('failed') await selects[3].setValue('failed')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(1) // App2 expect(rows.length).toBe(1) // App2
}) })
it('按 App 名称和版本号筛选', async () => {
const router = createMockRouter()
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
})
await flushPromises()
const selects = wrapper.findAll('.filter-select')
await selects[0].setValue('App1')
await wrapper.find('.version-filter').setValue('2.196')
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(1)
expect(rows[0].text()).toContain('App1')
expect(rows[0].text()).toContain('2.196.0')
})
it('已完成任务显示下载按钮', async () => { it('已完成任务显示下载按钮', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
@ -120,8 +138,7 @@ describe('HistoryView.vue', () => {
const buttons = firstRow.findAll('.action-btn') const buttons = firstRow.findAll('.action-btn')
const buttonTexts = buttons.map(b => b.text()) const buttonTexts = buttons.map(b => b.text())
expect(buttonTexts).toContain('dSYM') expect(buttonTexts).toContain('dSYM')
expect(buttonTexts).toContain('下载') expect(firstRow.find('.qr-thumb').exists()).toBe(true)
expect(buttonTexts).toContain('二维码')
}) })
it('空列表显示提示', async () => { it('空列表显示提示', async () => {
@ -148,7 +165,7 @@ describe('HistoryView.vue', () => {
expect(wrapper.vm.statusText('failed')).toBe('失败') expect(wrapper.vm.statusText('failed')).toBe('失败')
}) })
it('时间仅显示月和日Scheme 与状态单元格允许完整换行', async () => { it('显示 App 版本号Scheme 与状态单元格允许完整换行', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
@ -156,13 +173,13 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
expect(wrapper.vm.formatTime('2024-07-20T10:00:00')).toBe('7/20') expect(wrapper.vm.formatTime('2024-07-20T10:00:00')).toBe('7/20')
expect(wrapper.findAll('tbody tr')[0].text()).toContain('2.196.0')
expect(wrapper.find('.scheme-cell').text()).toBe('readoor31OtherPayLongSchemeName') expect(wrapper.find('.scheme-cell').text()).toBe('readoor31OtherPayLongSchemeName')
expect(wrapper.find('.status-cell').text()).toContain('已完成') expect(wrapper.find('.status-cell').text()).toContain('已完成')
}) })
it('点击查看日志跳转', async () => { it('点击查看日志打开日志弹窗', async () => {
const router = createMockRouter() const router = createMockRouter()
router.push = vi.fn()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
}) })
@ -171,7 +188,7 @@ describe('HistoryView.vue', () => {
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志') const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
await logBtn.trigger('click') await logBtn.trigger('click')
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } }) expect(wrapper.find('.log-modal').exists()).toBe(true)
}) })
it('点击二维码弹出弹窗', async () => { it('点击二维码弹出弹窗', async () => {
@ -181,11 +198,10 @@ describe('HistoryView.vue', () => {
}) })
await flushPromises() await flushPromises()
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码') await wrapper.find('.qr-thumb').trigger('click')
await qrBtn.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.qr-modal').exists()).toBe(true) expect(wrapper.find('.qr-preview-modal').exists()).toBe(true)
expect(wrapper.text()).toContain('下载二维码') expect(wrapper.text()).toContain('下载二维码')
}) })
}) })

View File

@ -4,6 +4,11 @@
<div class="header-row"> <div class="header-row">
<h2>打包历史</h2> <h2>打包历史</h2>
<div class="filters"> <div class="filters">
<select v-model="filterAppName" class="filter-select">
<option value="">全部 App</option>
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
</select>
<input v-model.trim="filterVersion" class="filter-select version-filter" type="search" placeholder="筛选 App 版本号">
<select v-model="filterBuildType" class="filter-select"> <select v-model="filterBuildType" class="filter-select">
<option value="">全部类型</option> <option value="">全部类型</option>
<option value="Ad_Hoc">Ad_Hoc</option> <option value="Ad_Hoc">Ad_Hoc</option>
@ -22,8 +27,8 @@
<table class="config-table"> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>App 名称</th>
<th>App</th> <th>App 版本号</th>
<th>打包类型</th> <th>打包类型</th>
<th>Scheme</th> <th>Scheme</th>
<th>状态</th> <th>状态</th>
@ -33,13 +38,8 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="task in filteredTasks" :key="task.id"> <tr v-for="task in filteredTasks" :key="task.id">
<td>
{{ formatTime(task.created_at) }}
<span v-if="task.status === 'completed' || task.status === 'failed'" class="duration-text">
{{ formatDuration(task.started_at, task.completed_at) }}
</span>
</td>
<td>{{ task.app_name }}</td> <td>{{ task.app_name }}</td>
<td>{{ task.app_version }}</td>
<td> <td>
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']"> <span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
{{ task.build_type }} {{ task.build_type }}
@ -162,7 +162,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken') const getToken = inject('getToken', () => '')
const tasks = ref([]) const tasks = ref([])
const authFetch = (url, options = {}) => { const authFetch = (url, options = {}) => {
@ -174,6 +174,8 @@ const authFetch = (url, options = {}) => {
} }
const filterBuildType = ref('') const filterBuildType = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const filterAppName = ref('')
const filterVersion = ref('')
const showLogModal = ref(false) const showLogModal = ref(false)
const logTask = ref(null) const logTask = ref(null)
const logLines = ref([]) const logLines = ref([])
@ -182,6 +184,16 @@ const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
let logWs = null let logWs = null
const getAppVersion = (task) => {
try {
return JSON.parse(task.config_json || '{}').VERSION || '-'
} catch {
return '-'
}
}
const appNames = computed(() => [...new Set(tasks.value.map(task => task.app_name).filter(Boolean))].sort())
const showQrPreview = (task) => { const showQrPreview = (task) => {
qrPreview.value = task qrPreview.value = task
} }
@ -190,6 +202,8 @@ const filteredTasks = computed(() => {
return tasks.value.filter(task => { return tasks.value.filter(task => {
// //
if (!filterStatus.value && task.status === 'cancelled') return false if (!filterStatus.value && task.status === 'cancelled') return false
if (filterAppName.value && task.app_name !== filterAppName.value) return false
if (filterVersion.value && !task.app_version.toLowerCase().includes(filterVersion.value.toLowerCase())) return false
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
if (filterStatus.value && task.status !== filterStatus.value) return false if (filterStatus.value && task.status !== filterStatus.value) return false
return true return true
@ -198,7 +212,8 @@ const filteredTasks = computed(() => {
onMounted(async () => { onMounted(async () => {
const res = await authFetch('/api/tasks?limit=100') const res = await authFetch('/api/tasks?limit=100')
tasks.value = await res.json() const data = await res.json()
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
}) })
const viewLogs = async (taskId) => { const viewLogs = async (taskId) => {
@ -298,11 +313,18 @@ const downloadObfMaps = (taskId) => {
} }
const deleteTask = async (taskId) => { const deleteTask = async (taskId) => {
if (!confirm('确定要删除这条打包记录吗?')) return if (!confirm('确定要删除这条打包记录及其远端分发文件吗?')) return
try { try {
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' }) const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId) if (res.ok) {
} catch {} tasks.value = tasks.value.filter(t => t.id !== taskId)
} else {
const data = await res.json().catch(() => ({}))
alert(data.detail || '删除失败,请稍后重试')
}
} catch {
alert('删除失败,请检查网络后重试')
}
} }
const formatTime = (t) => { const formatTime = (t) => {
@ -361,13 +383,13 @@ const errorCategoryLabel = (cat) => {
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; } .config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
.config-table td { font-size: 14px; } .config-table td { font-size: 14px; }
.config-table th:nth-child(1) { width: 10%; } .config-table th:nth-child(1) { width: 14%; }
.config-table th:nth-child(2) { width: 12%; } .config-table th:nth-child(2) { width: 12%; }
.config-table th:nth-child(3) { width: 9%; } .config-table th:nth-child(3) { width: 10%; }
.config-table th:nth-child(4) { width: 18%; } .config-table th:nth-child(4) { width: 18%; }
.config-table th:nth-child(5) { width: 17%; } .config-table th:nth-child(5) { width: 15%; }
.config-table th:nth-child(6) { width: 12%; } .config-table th:nth-child(6) { width: 12%; }
.config-table th:nth-child(7) { width: 22%; } .config-table th:nth-child(7) { width: 19%; }
.config-table tr:hover { background: #fafafa; } .config-table tr:hover { background: #fafafa; }
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } .build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
@ -434,9 +456,7 @@ const errorCategoryLabel = (cat) => {
.meta-label { font-size: 12px; color: #999; } .meta-label { font-size: 12px; color: #999; }
.meta-value { font-size: 13px; color: #333; font-weight: 500; } .meta-value { font-size: 13px; color: #333; font-weight: 500; }
.duration-text { .version-filter { width: 150px; }
display: block; font-size: 11px; color: #8b949e; margin-top: 2px;
}
/* 日志操作栏 */ /* 日志操作栏 */
.log-modal-actions { .log-modal-actions {

View File

@ -167,3 +167,48 @@ def test_cancel_task(mock_queue, client, tmp_config):
# 验证状态已更新 # 验证状态已更新
task = client.get(f"/api/tasks/{task_id}").json() task = client.get(f"/api/tasks/{task_id}").json()
assert task["status"] == "cancelled" assert task["status"] == "cancelled"
def test_delete_completed_task_removes_remote_artifacts(client, tmp_config):
from backend.database import SessionLocal
from backend.models import Task
task = Task(
id="completed-task", app_id="1", app_name="测试App", build_type="Ad_Hoc",
scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
oss_url="https://files.example.com/test/iOS/1_2_0_0_0_main.html",
config_json=json.dumps({"APPID": "1", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "test"}),
)
db = SessionLocal()
db.add(task)
db.commit()
db.close()
with patch("backend.services.distribution.delete_published_artifacts") as delete:
resp = client.delete("/api/tasks/completed-task/delete")
assert resp.status_code == 200
delete.assert_called_once()
assert client.get("/api/tasks/completed-task").status_code == 404
def test_delete_task_keeps_shared_remote_artifact(client, tmp_config):
from backend.database import SessionLocal
from backend.models import Task
db = SessionLocal()
for task_id in ("old-task", "new-task"):
db.add(Task(
id=task_id, app_id="1", app_name="测试App", build_type="Ad_Hoc",
scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
oss_url="https://files.example.com/test/iOS/1_2_0_0_0_main.html",
))
db.commit()
db.close()
with patch("backend.services.distribution.delete_published_artifacts") as delete:
resp = client.delete("/api/tasks/old-task/delete")
assert resp.status_code == 200
assert "仍被其他记录引用" in resp.json()["message"]
delete.assert_not_called()

View File

@ -3,6 +3,8 @@ import plistlib
from unittest.mock import patch from unittest.mock import patch
from backend.services.distribution import ( from backend.services.distribution import (
_artifact_stem,
delete_published_artifacts,
_write_distribution_files, _write_distribution_files,
_write_download_page, _write_download_page,
_write_manifest, _write_manifest,
@ -10,6 +12,11 @@ from backend.services.distribution import (
) )
def test_distribution_artifact_name_contains_sanitized_branch():
assert _artifact_stem({"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "feature/pay-v2", "BUILD_TYPE": "Ad_Hoc"}) == "100_2_0_0_0_feature_pay-v2_adhoc"
assert _artifact_stem({"APPID": "100", "VERSION": "2.0.0.0"}) == "100_2_0_0_0"
def test_distribution_files_use_current_service_config(tmp_path): def test_distribution_files_use_current_service_config(tmp_path):
ipa = tmp_path / "source.ipa" ipa = tmp_path / "source.ipa"
ipa.write_bytes(b"ipa") ipa.write_bytes(b"ipa")
@ -35,20 +42,21 @@ def test_app_store_distribution_uploads_only_ipa(tmp_path):
"APPID": "100", "APPID": "100",
"VERSION": "2.0.0", "VERSION": "2.0.0",
"BUILD_TYPE": "App_Store", "BUILD_TYPE": "App_Store",
"SOURCE_BRANCH": "main",
"OSS_FLODER": "readoor", "OSS_FLODER": "readoor",
"_upload_config": {"mode": "oss", "oss": {}}, "_upload_config": {"mode": "oss", "oss": {}},
} }
with patch( with patch(
"backend.services.distribution._upload_oss", "backend.services.distribution._upload_oss",
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0.ipa"}, return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0_main_appstore.ipa"},
) as upload: ) as upload:
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build") download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
uploaded_files = upload.call_args.args[1] uploaded_files = upload.call_args.args[1]
assert len(uploaded_files) == 1 assert len(uploaded_files) == 1
assert uploaded_files[0][0].suffix == ".ipa" assert uploaded_files[0][0].suffix == ".ipa"
assert download_url.endswith("100_2_0_0.ipa") assert download_url.endswith("100_2_0_0_main_appstore.ipa")
assert qr_path == "" assert qr_path == ""
@ -59,6 +67,7 @@ def test_adhoc_distribution_uploads_qrcode(tmp_path):
"APPID": "100", "APPID": "100",
"VERSION": "2.0.0", "VERSION": "2.0.0",
"BUILD_TYPE": "Ad_Hoc", "BUILD_TYPE": "Ad_Hoc",
"SOURCE_BRANCH": "dev",
"OSS_FLODER": "readoor", "OSS_FLODER": "readoor",
"_upload_config": {"mode": "oss", "oss": {}}, "_upload_config": {"mode": "oss", "oss": {}},
} }
@ -70,8 +79,21 @@ def test_adhoc_distribution_uploads_qrcode(tmp_path):
with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload: with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload:
download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build") download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build")
assert download_url.endswith("100_2_0_0.html") assert download_url.endswith("100_2_0_0_dev_adhoc.html")
assert qr_url.endswith("100_2_0_0.png") assert qr_url.endswith("100_2_0_0_dev_adhoc.png")
assert [call.args[1][0][0].suffix for call in upload.call_args_list] == [ assert [call.args[1][0][0].suffix for call in upload.call_args_list] == [
".ipa", ".plist", ".html", ".png", ".ipa", ".plist", ".html", ".png",
] ]
def test_delete_adhoc_artifacts_deletes_all_remote_files():
config = {"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "readoor"}
with patch("backend.services.distribution._delete_oss") as delete:
delete_published_artifacts(config, {"mode": "oss", "oss": {}})
assert delete.call_args.args[1] == [
"readoor/iOS/100_2_0_0_0_main_adhoc.ipa",
"readoor/iOS/100_2_0_0_0_main_adhoc.plist",
"readoor/iOS/100_2_0_0_0_main_adhoc.html",
"readoor/iOS/100_2_0_0_0_main_adhoc.png",
]