Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
388 changes: 296 additions & 92 deletions apps/server/src/routes/management.ts

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions apps/server/src/storage/d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,27 @@ export class D1StorageProvider implements StorageProvider {
return pkg;
}

// disabled/mandatory 토글 전용 부분 UPDATE. updatePackage(여러 필드 통째 SET)와 달리
// 대상 컬럼만 갱신한다 — 히스토리 캐시가 isolate별 메모리라 stale 캐시 기반으로 전체 필드를
// 쓰면 다른 isolate에서 방금 바꾼 필드를 되돌릴 수 있어(lost update), 토글은 이 경로를 쓴다.
async updatePackageMetadata(
deploymentId: string,
label: string,
updates: { isDisabled?: boolean; isMandatory?: boolean },
): Promise<void> {
await this.db
.update(schema.packages)
.set(updates)
.where(
and(
eq(schema.packages.deploymentId, deploymentId),
eq(schema.packages.label, label),
isNull(schema.packages.deletedAt),
),
);
await this.cache.del(this.cacheKeys.package(deploymentId));
}

async getPackageHistory(
accountId: string,
appId: string,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/storage/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ export interface StorageProvider {

updatePackage(pkg: Package, deploymentId: string): Promise<Package>;

updatePackageMetadata(
deploymentId: string,
label: string,
updates: { isDisabled?: boolean; isMandatory?: boolean },
): Promise<void>;

getPackageHistory(
accountId: string,
appId: string,
Expand Down
36 changes: 23 additions & 13 deletions apps/server/src/utils/slack.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import type { Env } from "../types/env";

type ReleaseActionType = "Uploaded" | "Enabled" | "Disabled";
type ReleaseActionType = "Uploaded" | "UploadFailed" | "Disabled" | "Mandatory";

const SLACK_WEBHOOK_BASE = "https://hooks.slack.com/services";

const ACTION_HEADER: Record<ReleaseActionType, string> = {
Uploaded: "새 릴리즈 업로드",
Enabled: "릴리즈 활성화",
Disabled: "릴리즈 비활성화",
};

// 이모지·헤더는 액션 종류만 구분한다. on/off는 헤더 텍스트가 isDisabled/isMandatory 값으로 표현.
const ACTION_EMOJI: Record<ReleaseActionType, string> = {
Uploaded: "🚀",
Enabled: "🟢",
Disabled: "🔴",
UploadFailed: "🚨",
Disabled: "🧊",
Mandatory: "🔥",
};

type ReleaseNotificationType = {
Expand All @@ -25,6 +21,7 @@ type ReleaseNotificationType = {
isDisabled?: boolean;
action: ReleaseActionType;
releasedBy?: string;
errorMessage?: string;
};

/**
Expand All @@ -42,11 +39,24 @@ export const sendReleaseNotification = async (
const platform = info.appName.toLowerCase().includes("android")
? "Android"
: "iOS";
const header = `${ACTION_EMOJI[info.action]} ${platform} · ${ACTION_HEADER[info.action]}`;
const headerLabel =
info.action === "Uploaded"
? "새 릴리즈 업로드"
: info.action === "UploadFailed"
? "릴리즈 업로드 실패"
: info.action === "Disabled"
? `Disabled ${info.isDisabled ?? false}`
: `Mandatory ${info.isMandatory ?? false}`;
const header = `${ACTION_EMOJI[info.action]} ${platform} · ${headerLabel}`;

// 라벨 + 앱 버전을 굵게, 설명은 있을 때만 다음 줄에.
const summary = `*${info.label || "-"}* · App ${info.appVersion}`;
const body = info.description ? `${summary}\n${info.description}` : summary;
// 라벨 + 앱 버전을 굵게, 설명·에러는 있을 때만 다음 줄에.
const body = [
`*${info.label || "-"}* · App ${info.appVersion}`,
info.description,
info.errorMessage && `\`${info.errorMessage}\``,
]
.filter(Boolean)
.join("\n");

// 부가 정보는 작은 context 줄로 (Mandatory · Disabled · 작성자)
const context = [
Expand Down
146 changes: 146 additions & 0 deletions apps/server/test/routes/management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1797,5 +1797,151 @@ describe("Management Routes", () => {
expect(data.release.appVersion).toBe("1.0.0");
});
});

describe("PATCH /apps/:appName/deployments/:deploymentName/release/{disabled,mandatory}", () => {
let app: typeof schema.app.$inferInsert;
let deployment: typeof schema.deployment.$inferInsert;

beforeEach(async () => {
app = createTestApp();
await db.insert(schema.app).values(app);
await db.insert(schema.collaborator).values({
appId: app.id,
accountId: auth.getCurrentAccountId(),
permission: "Owner",
});
deployment = createTestDeployment(app.id);
await db.insert(schema.deployment).values(deployment);
// v1: disabled 상태, v2: mandatory 상태(최신) — 부분 UPDATE가 다른 필드를 건드리지 않는지 검증용
const package1 = createTestPackage(deployment.id, {
label: "v1",
appVersion: "1.0.0",
isDisabled: true,
uploadTime: Date.now() - 1000,
});
const package2 = createTestPackage(deployment.id, {
label: "v2",
appVersion: "1.0.1",
isMandatory: true,
uploadTime: Date.now(),
});
await db.insert(schema.packages).values(package1);
await db.insert(schema.packages).values(package2);
await Promise.all([
createTestBlob(package1.blobPath, "blob1"),
createTestBlob(package1.manifestBlobPath as string, "blob2"),
createTestBlob(package2.blobPath, "blob3"),
createTestBlob(package2.manifestBlobPath as string, "blob4"),
]);
});

const fetchToggle = (
kind: "disabled" | "mandatory",
body: object,
headers: HeadersInit = {},
) =>
SELF.fetch(
`https://example.com/apps/${app.name}/deployments/${deployment.name}/release/${kind}`,
{ method: "PATCH", headers, body: JSON.stringify(body) },
);

const getRow = async (label: string) => {
const [row] = await db
.select()
.from(schema.packages)
.where(
and(
eq(schema.packages.deploymentId, deployment.id as string),
eq(schema.packages.label, label),
),
);
return row;
};

it("disables a release by label without touching isMandatory", async () => {
const headers = await auth.getAuthHeaders();
const response = await fetchToggle(
"disabled",
{ label: "v2", isDisabled: true },
headers,
);

expect(response.status).toBe(200);
const data: any = await response.json();
expect(data.release.label).toBe("v2");
expect(data.release.isDisabled).toBe(true);

const row = await getRow("v2");
expect(row.isDisabled).toBe(true);
expect(row.isMandatory).toBe(true); // 부분 UPDATE — mandatory 불변
});

it("enables a disabled release", async () => {
const headers = await auth.getAuthHeaders();
const response = await fetchToggle(
"disabled",
{ label: "v1", isDisabled: false },
headers,
);

expect(response.status).toBe(200);
const row = await getRow("v1");
expect(row.isDisabled).toBe(false);
});

it("sets mandatory by label without touching isDisabled", async () => {
const headers = await auth.getAuthHeaders();
const response = await fetchToggle(
"mandatory",
{ label: "v1", isMandatory: true },
headers,
);

expect(response.status).toBe(200);
const data: any = await response.json();
expect(data.release.label).toBe("v1");
expect(data.release.isMandatory).toBe(true);

const row = await getRow("v1");
expect(row.isMandatory).toBe(true);
expect(row.isDisabled).toBe(true); // 부분 UPDATE — disabled 불변
});

it("targets the latest release when label is omitted", async () => {
const headers = await auth.getAuthHeaders();
const response = await fetchToggle(
"mandatory",
{ isMandatory: false },
headers,
);

expect(response.status).toBe(200);
const data: any = await response.json();
expect(data.release.label).toBe("v2");

const v2 = await getRow("v2");
expect(v2.isMandatory).toBe(false);
const v1 = await getRow("v1");
expect(v1.isMandatory).toBe(false); // 다른 행 불변
});

it("returns 404 for unknown label", async () => {
const headers = await auth.getAuthHeaders();
const response = await fetchToggle(
"disabled",
{ label: "v99", isDisabled: true },
headers,
);
expect(response.status).toBe(404);
});

it("returns 401 when not authenticated", async () => {
const response = await fetchToggle("mandatory", {
label: "v1",
isMandatory: true,
});
expect(response.status).toBe(401);
});
});
});

2 changes: 2 additions & 0 deletions apps/server/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export default defineWorkersProject(async () => {
d1Databases: ["DB"],
bindings: {
TEST_MIGRATIONS: migrations,
// .dev.vars의 실제 webhook이 로드되면 테스트가 실채널로 Slack을 쏜다. 반드시 빈 값 고정.
CODE_PUSH_NOTI_SLACK: "",
GITHUB_CLIENT_ID: "xxx",
GITHUB_CLIENT_SECRET: "xxx",
GITHUB_ORG: "test-org",
Expand Down
Loading
Loading