The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.
Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
385 lines
20 KiB
TypeScript
385 lines
20 KiB
TypeScript
// 데스크톱 동기화 엔진 ↔ 실제 Supabase(로컬 스택) 통합 검증.
|
|
// 모바일 앱이 쓰는 것과 같은 테이블·RPC·RLS를 상대로, 모바일 역할 클라이언트와 데스크톱 엔진이
|
|
// 서로의 생성·수정·삭제를 주고받는지 확인한다.
|
|
//
|
|
// 실행: 로컬 스택(`supabase start`, server/) 기동 후
|
|
// D3RO_SYNC_IT_SUPABASE_URL=http://127.0.0.1:55321 \
|
|
// D3RO_SYNC_IT_ANON_KEY=<publishable> D3RO_SYNC_IT_SERVICE_KEY=<secret> \
|
|
// vitest run tests/integration/cross-device-sync.supabase.test.ts
|
|
// 환경변수가 없으면 건너뛴다(CI 기본).
|
|
|
|
import fs from 'fs'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
import { eq } from 'drizzle-orm'
|
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
|
import { nodeRealtimeTransport } from '../../src/main/services/sync/realtime-transport'
|
|
import { createTestDb } from '../helpers/createTestDb'
|
|
import { bindTestDatabase, unbindTestDatabase } from '../../src/main/db'
|
|
import { dictionary, history, meetingMemos, meetingSessions, memoTags, ragChunks, ragDocuments } from '../../src/main/db/schema'
|
|
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService'
|
|
import { resetRAGServiceForTests } from '../../src/main/services/RAGService'
|
|
import {
|
|
getCustomInstructionService,
|
|
resetCustomInstructionServiceForTests,
|
|
} from '../../src/main/services/CustomInstructionService'
|
|
import {
|
|
getDictationTemplateService,
|
|
resetDictationTemplateServiceForTests,
|
|
} from '../../src/main/services/DictationTemplateService'
|
|
import { resetMeetingDocTemplateServiceForTests } from '../../src/main/services/MeetingDocTemplateService'
|
|
import { SyncEngine } from '../../src/main/services/sync/SyncEngine'
|
|
import { SupabaseSyncRemote } from '../../src/main/services/sync/supabase-sync-remote'
|
|
import { enqueueChange, outboxCounts } from '../../src/main/services/sync/sync-outbox'
|
|
import { memoTagKey } from '../../src/main/services/sync/memo-tag-sync'
|
|
import { checkInDesktopDevice } from '../../src/main/services/sync/device-registration'
|
|
|
|
const URL = process.env.D3RO_SYNC_IT_SUPABASE_URL
|
|
const ANON = process.env.D3RO_SYNC_IT_ANON_KEY
|
|
const SERVICE = process.env.D3RO_SYNC_IT_SERVICE_KEY
|
|
const enabled = Boolean(URL && ANON && SERVICE)
|
|
|
|
// 데스크톱 CloudSyncService와 같은 클라이언트 설정 (Node 20에는 전역 WebSocket이 없다)
|
|
const noPersist = {
|
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
realtime: { transport: nodeRealtimeTransport },
|
|
}
|
|
|
|
async function signedInClient(email: string, password: string): Promise<SupabaseClient> {
|
|
const client = createClient(URL!, ANON!, noPersist)
|
|
const { error } = await client.auth.signInWithPassword({ email, password })
|
|
if (error) throw error
|
|
return client
|
|
}
|
|
|
|
function must<T>(result: { data: T; error: { message: string } | null }): T {
|
|
if (result.error) throw new Error(result.error.message)
|
|
return result.data
|
|
}
|
|
|
|
describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
|
|
let admin: SupabaseClient
|
|
let desktop: SupabaseClient
|
|
let mobile: SupabaseClient
|
|
let userId: string
|
|
let testDb: ReturnType<typeof createTestDb>
|
|
let engine: SyncEngine
|
|
|
|
beforeAll(async () => {
|
|
admin = createClient(URL!, SERVICE!, noPersist)
|
|
const email = `sync-it-${Date.now()}@example.com`
|
|
const password = `Sync-it-${crypto.randomUUID()}`
|
|
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true })
|
|
if (created.error || !created.data.user) throw created.error ?? new Error('no user')
|
|
userId = created.data.user.id
|
|
desktop = await signedInClient(email, password)
|
|
mobile = await signedInClient(email, password)
|
|
|
|
testDb = createTestDb()
|
|
bindTestDatabase(testDb.db, userId)
|
|
initInMemoryConfig()
|
|
resetCustomInstructionServiceForTests()
|
|
resetDictationTemplateServiceForTests()
|
|
resetMeetingDocTemplateServiceForTests()
|
|
getCustomInstructionService().initialize()
|
|
engine = new SyncEngine({ remote: new SupabaseSyncRemote(desktop), userId })
|
|
})
|
|
|
|
afterAll(async () => {
|
|
engine?.dispose()
|
|
unbindTestDatabase()
|
|
resetInMemoryConfig()
|
|
testDb?.close()
|
|
if (userId) await admin.auth.admin.deleteUser(userId)
|
|
})
|
|
|
|
it('양방향 최초 동기화 — 모바일 기존 데이터 수신 + 데스크톱 데이터 업로드', async () => {
|
|
const db = testDb.db
|
|
const now = Date.now()
|
|
|
|
// 모바일이 먼저 만들어 둔 것
|
|
const phoneHistory = crypto.randomUUID()
|
|
must(await mobile.from('history').insert({ id: phoneHistory, user_id: userId, original_text: 'from phone', duration: 2, is_favorite: true }))
|
|
await mobile.rpc('mobile_add_memo_tag_v1', { p_history_id: phoneHistory, p_tag: 'Phone Tag' }).throwOnError()
|
|
must(await mobile.from('dictionary').insert({ id: crypto.randomUUID(), user_id: userId, word: 'kubernetes', category: 'user' }))
|
|
const phoneMeeting = crypto.randomUUID()
|
|
must(await mobile.from('meetings').insert({ id: phoneMeeting, user_id: userId, title: 'phone meeting', status: 'completed' }))
|
|
must(await mobile.from('meeting_memos').insert({ id: crypto.randomUUID(), meeting_id: phoneMeeting, user_id: userId, content: 'phone memo', timestamp_ms: 3 }))
|
|
must(await mobile.from('custom_instructions').insert({ user_id: userId, name: 'Phone command', prompt: 'Be brief', icon: 'sparkles' }))
|
|
await mobile
|
|
.rpc('create_user_template_v1', {
|
|
p_template_kind: 'meeting_document',
|
|
p_name: 'Phone template',
|
|
p_description: null,
|
|
p_fields: [],
|
|
p_output_format: null,
|
|
p_system_prompt: 'Summarise',
|
|
})
|
|
.throwOnError()
|
|
|
|
// 데스크톱에만 있는 것
|
|
const deskHistory = crypto.randomUUID()
|
|
db.insert(history).values({ id: deskHistory, originalText: 'from desktop', duration: 1.5, createdAt: now, updatedAt: now }).run()
|
|
db.insert(memoTags).values({ id: crypto.randomUUID(), historyId: deskHistory, tag: 'desk', createdAt: now }).run()
|
|
db.insert(dictionary).values({ id: crypto.randomUUID(), word: 'Kubernetes', category: 'user', usageCount: 4, createdAt: now, updatedAt: now }).run()
|
|
const m1 = crypto.randomUUID()
|
|
const m2 = crypto.randomUUID()
|
|
for (const m of [m1, m2]) {
|
|
db.insert(meetingSessions).values({ id: m, title: `desk ${m.slice(0, 4)}`, status: 'completed', startedAt: now, createdAt: now, updatedAt: now }).run()
|
|
db.insert(meetingMemos).values({ id: crypto.randomUUID(), sessionId: m, content: 'desk memo', timestampMs: 1, createdAt: now }).run()
|
|
}
|
|
const deskCommand = getCustomInstructionService().create({ name: 'Desk command', description: '', prompt: 'Rewrite formally' })
|
|
const deskTemplate = getDictationTemplateService().create({
|
|
name: 'Desk dictation',
|
|
description: 'd',
|
|
fields: [{ id: 'field0', name: 'body', label: 'Body', promptText: 'Say it', required: true, maxDurationSec: 60 }],
|
|
outputFormat: '{{body}}',
|
|
})
|
|
|
|
const result = await engine.runFullSync()
|
|
expect(result.errors).toEqual([])
|
|
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
|
|
|
// 서버(모바일이 보는 것)
|
|
const remoteHistory = must(await mobile.from('history').select('id').eq('user_id', userId))
|
|
expect(remoteHistory.map((r) => r.id).sort()).toEqual([phoneHistory, deskHistory].sort())
|
|
const remoteMemos = must(await mobile.from('meeting_memos').select('meeting_id').eq('user_id', userId))
|
|
expect(new Set(remoteMemos.map((r) => r.meeting_id))).toEqual(new Set([phoneMeeting, m1, m2]))
|
|
const remoteTags = must(await mobile.rpc('mobile_list_memo_tags_v1')) as Array<{ normalized_tag: string }>
|
|
expect(remoteTags.map((r) => r.normalized_tag).sort()).toEqual(['desk', 'phone tag'])
|
|
const remoteDict = must(await mobile.from('dictionary').select('word').eq('user_id', userId))
|
|
expect(remoteDict).toHaveLength(1)
|
|
const remoteCommands = must(await mobile.from('custom_instructions').select('id,name').eq('user_id', userId).is('builtin_key', null))
|
|
expect(remoteCommands.map((r) => r.name).sort()).toEqual(['Desk command', 'Phone command'])
|
|
expect(remoteCommands.some((r) => r.id === deskCommand.id)).toBe(true)
|
|
const remoteTemplate = must(await mobile.from('user_templates').select('id,name').eq('id', deskTemplate.id).maybeSingle())
|
|
expect(remoteTemplate?.name).toBe('Desk dictation')
|
|
|
|
// 데스크톱(로컬)
|
|
const local = db.select().from(history).where(eq(history.id, phoneHistory)).get()
|
|
expect(local?.isFavorite).toBe(true)
|
|
expect(db.select().from(memoTags).all().map((t) => t.tag).sort()).toEqual(['desk', 'phone tag'])
|
|
expect(db.select().from(dictionary).all()).toHaveLength(1)
|
|
expect(db.select().from(meetingMemos).where(eq(meetingMemos.sessionId, phoneMeeting)).all()).toHaveLength(1)
|
|
expect(getCustomInstructionService().getAll().some((i) => i.name === 'Phone command')).toBe(true)
|
|
})
|
|
|
|
it('모바일 수정·삭제 → 데스크톱, 데스크톱 삭제 → 모바일', async () => {
|
|
const db = testDb.db
|
|
const rows = must(await mobile.from('history').select('id,original_text,revision').eq('user_id', userId))
|
|
const phone = rows.find((r) => r.original_text === 'from phone')!
|
|
const desk = rows.find((r) => r.original_text === 'from desktop')!
|
|
|
|
// 모바일식 낙관적 동시성 수정
|
|
must(
|
|
await mobile
|
|
.from('history')
|
|
.update({ original_text: 'phone edited', revision: Number(phone.revision) + 1 })
|
|
.eq('id', phone.id)
|
|
.eq('revision', phone.revision)
|
|
)
|
|
must(await mobile.from('meetings').delete().eq('user_id', userId).eq('title', 'phone meeting'))
|
|
await engine.pull()
|
|
expect(db.select().from(history).where(eq(history.id, phone.id)).get()?.originalText).toBe('phone edited')
|
|
expect(db.select().from(meetingSessions).all().map((m) => m.title)).not.toContain('phone meeting')
|
|
|
|
// 데스크톱 즐겨찾기 → 서버 revision 증가(모바일 충돌 감지)
|
|
db.update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, desk.id)).run()
|
|
enqueueChange('history', desk.id, 'upsert')
|
|
await engine.flush()
|
|
const after = must(await mobile.from('history').select('is_favorite,revision').eq('id', desk.id).single())
|
|
expect(after.is_favorite).toBe(true)
|
|
expect(Number(after.revision)).toBeGreaterThan(Number(desk.revision))
|
|
|
|
// 데스크톱 삭제
|
|
db.delete(memoTags).where(eq(memoTags.historyId, desk.id)).run()
|
|
db.delete(history).where(eq(history.id, desk.id)).run()
|
|
enqueueChange('history', desk.id, 'delete')
|
|
enqueueChange('memo_tags', memoTagKey(desk.id, 'desk'), 'delete')
|
|
const flushed = await engine.flush()
|
|
expect(flushed.errors).toEqual([])
|
|
const gone = must(await mobile.from('history').select('id').eq('id', desk.id))
|
|
expect(gone).toEqual([])
|
|
})
|
|
|
|
it('모바일 데이터 내보내기가 동기화된 데이터로도 성공한다 (v1 키 계약)', async () => {
|
|
const exported = must(await mobile.rpc('export_account_portability')) as Array<{ canonical_payload: string }>
|
|
const payload = JSON.parse(exported[0].canonical_payload) as {
|
|
datasets: { meeting_memos: Array<Record<string, unknown>>; meetings: Array<Record<string, unknown>> }
|
|
}
|
|
for (const memo of payload.datasets.meeting_memos) {
|
|
expect(Object.keys(memo).sort()).toEqual(['content', 'created_at', 'id', 'meeting_id', 'timestamp_ms', 'user_id'])
|
|
}
|
|
for (const meeting of payload.datasets.meetings) {
|
|
expect(meeting).not.toHaveProperty('attendees')
|
|
}
|
|
})
|
|
|
|
it('모바일 생성·삭제가 Realtime(행 + 삭제 기록)으로 데스크톱에 즉시 알려진다', async () => {
|
|
const session = must(await desktop.auth.getSession()).session
|
|
desktop.realtime.setAuth(session!.access_token)
|
|
const seen: string[] = []
|
|
const channel = desktop
|
|
.channel(`it-sync:${userId}`)
|
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'history', filter: `user_id=eq.${userId}` }, (p) =>
|
|
seen.push(`history:${p.eventType}`)
|
|
)
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: 'INSERT', schema: 'public', table: 'sync_tombstones', filter: `user_id=eq.${userId}` },
|
|
() => seen.push('tombstone')
|
|
)
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('realtime subscribe timeout')), 15_000)
|
|
channel.subscribe((status, err) => {
|
|
if (status !== 'SUBSCRIBED') seen.push(`status:${status}:${err?.message ?? ''}`)
|
|
if (status === 'SUBSCRIBED') {
|
|
clearTimeout(timer)
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
// 구독 직후에는 서버의 변경 감시 등록이 끝나지 않았을 수 있다.
|
|
await new Promise((r) => setTimeout(r, 1500))
|
|
const id = crypto.randomUUID()
|
|
must(await mobile.from('history').insert({ id, user_id: userId, original_text: 'rt', duration: 1 }))
|
|
await new Promise((r) => setTimeout(r, 1000))
|
|
// 필터 채널에는 DELETE 이벤트가 오지 않는다(Supabase 제약) — 삭제는 sync_tombstones INSERT로 전달된다.
|
|
must(await mobile.from('history').delete().eq('id', id))
|
|
const deadline = Date.now() + 15_000
|
|
while (Date.now() < deadline && !(seen.includes('history:INSERT') && seen.includes('tombstone'))) {
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
}
|
|
await desktop.removeChannel(channel)
|
|
expect(seen).toContain('history:INSERT')
|
|
expect(seen).toContain('tombstone')
|
|
}, 45_000)
|
|
|
|
it('지식 문서(원문 청크)·설정·녹음이 실제 RLS/저장소 정책을 통과해 오간다', async () => {
|
|
const db = testDb.db
|
|
resetRAGServiceForTests()
|
|
// 로컬 임베딩 서버(Ollama)는 없다고 보고 원문 저장 경로만 확인한다(Supabase 호출은 통과시킨다).
|
|
const realFetch = globalThis.fetch
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) =>
|
|
String(input instanceof Request ? input.url : input).includes('11434')
|
|
? Promise.reject(new TypeError('fetch failed'))
|
|
: realFetch(input, init)
|
|
)
|
|
|
|
// 데스크톱 지식 문서
|
|
const docId = crypto.randomUUID()
|
|
db.insert(ragDocuments).values({ id: docId, fileName: 'desk.md', filePath: '', fileType: 'md', chunkCount: 2, indexed: false, addedAt: Date.now() }).run()
|
|
;['alpha chunk', 'beta chunk'].forEach((content, chunkIndex) =>
|
|
db.insert(ragChunks).values({ id: crypto.randomUUID(), documentId: docId, content, embedding: '', chunkIndex }).run()
|
|
)
|
|
enqueueChange('knowledge_documents', docId, 'upsert')
|
|
|
|
// 모바일 지식 문서
|
|
const phoneDoc = crypto.randomUUID()
|
|
must(await mobile.from('knowledge_documents').insert({ id: phoneDoc, user_id: userId, title: 'Phone doc', file_name: 'phone.txt', file_type: 'txt', chunk_count: 1 }))
|
|
must(await mobile.from('knowledge_chunks').insert({ document_id: phoneDoc, chunk_index: 0, content: 'from the phone' }))
|
|
|
|
// 녹음이 있는 데스크톱 기록
|
|
const audioDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-it-audio-'))
|
|
const audioFile = path.join(audioDir, 'clip.wav')
|
|
fs.writeFileSync(audioFile, Buffer.from('RIFF----WAVEfmt integration audio bytes'))
|
|
const clipId = crypto.randomUUID()
|
|
db.insert(history).values({ id: clipId, originalText: 'with audio', duration: 1, audioLocalPath: audioFile, createdAt: Date.now(), updatedAt: Date.now() }).run()
|
|
enqueueChange('history', clipId, 'upsert')
|
|
enqueueChange('history_audio', clipId, 'upsert')
|
|
|
|
// 설정: 데스크톱 변경 → 서버, 이후 모바일 변경 → 데스크톱
|
|
configSet('theme', 'dark')
|
|
enqueueChange('user_settings', 'self', 'upsert')
|
|
|
|
const pushed = await engine.flush()
|
|
expect(pushed.errors).toEqual([])
|
|
const remoteChunks = must(await mobile.from('knowledge_chunks').select('content').eq('document_id', docId).order('chunk_index'))
|
|
expect(remoteChunks.map((c) => c.content)).toEqual(['alpha chunk', 'beta chunk'])
|
|
const audio = must(await mobile.from('audio_files').select('storage_key,upload_status,mime_type').eq('history_id', clipId))
|
|
expect(audio).toHaveLength(1)
|
|
expect(audio[0].upload_status).toBe('uploaded')
|
|
const signed = await mobile.storage.from('audio').createSignedUrl(String(audio[0].storage_key), 60)
|
|
expect(signed.error).toBeNull()
|
|
const settings = must(await mobile.from('user_settings').select('theme_mode,revision').eq('user_id', userId).single())
|
|
expect(settings.theme_mode).toBe('dark')
|
|
|
|
must(
|
|
await mobile
|
|
.from('user_settings')
|
|
.update({ locale: 'en', revision: Number(settings.revision) + 1 })
|
|
.eq('user_id', userId)
|
|
.eq('revision', settings.revision)
|
|
)
|
|
await engine.pull()
|
|
expect(configGet('language')).toBe('en')
|
|
const pulledChunks = db.select().from(ragChunks).where(eq(ragChunks.documentId, phoneDoc)).all()
|
|
expect(pulledChunks.map((c) => c.content)).toEqual(['from the phone'])
|
|
|
|
// 모바일이 지식 문서를 지우면 데스크톱에서도 사라진다(삭제 기록)
|
|
must(await mobile.from('knowledge_documents').delete().eq('id', phoneDoc))
|
|
await engine.pull()
|
|
expect(db.select().from(ragDocuments).where(eq(ragDocuments.id, phoneDoc)).all()).toEqual([])
|
|
|
|
// 데스크톱 기록 삭제 → 저장소 녹음도 정리
|
|
db.delete(history).where(eq(history.id, clipId)).run()
|
|
enqueueChange('history', clipId, 'delete')
|
|
const deleted = await engine.flush()
|
|
expect(deleted.errors).toEqual([])
|
|
expect(must(await mobile.from('audio_files').select('id').eq('user_id', userId).eq('storage_key', String(audio[0].storage_key)))).toEqual([])
|
|
fs.rmSync(audioDir, { recursive: true, force: true })
|
|
vi.restoreAllMocks()
|
|
}, 60_000)
|
|
|
|
it('데스크톱 전사가 폰이 그리는 구간으로 올라가고, 고친 프리셋 프롬프트가 폰 명령에 쓰인다', async () => {
|
|
const db = testDb.db
|
|
const meetingId = crypto.randomUUID()
|
|
const now = Date.now()
|
|
db.insert(meetingSessions).values({
|
|
id: meetingId,
|
|
title: 'segments',
|
|
status: 'completed',
|
|
startedAt: now,
|
|
rawTranscript: '[00:00] one\n[00:04] two\n[00:09] three',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).run()
|
|
enqueueChange('meetings', meetingId, 'upsert')
|
|
getCustomInstructionService().update('builtin-summarize', { prompt: '두 줄로 요약' })
|
|
enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert')
|
|
const first = await engine.flush()
|
|
expect(first.errors).toEqual([])
|
|
const segments = must(await mobile.from('transcripts').select('segment_index,text,edited').eq('meeting_id', meetingId).order('segment_index'))
|
|
expect(segments.map((s) => s.text)).toEqual(['one', 'two', 'three'])
|
|
|
|
db.update(meetingSessions).set({ editedTranscript: '[00:00] [화자 1] one fixed', updatedAt: Date.now() }).where(eq(meetingSessions.id, meetingId)).run()
|
|
enqueueChange('meetings', meetingId, 'upsert')
|
|
expect((await engine.flush()).errors).toEqual([])
|
|
const edited = must(await mobile.from('transcripts').select('text,speaker,edited').eq('meeting_id', meetingId))
|
|
expect(edited).toEqual([{ text: 'one fixed', speaker: '화자 1', edited: true }])
|
|
|
|
const summarize = must(await mobile.from('custom_instructions').select('prompt').eq('user_id', userId).eq('builtin_key', 'summarize').single())
|
|
expect(summarize.prompt).toBe('두 줄로 요약')
|
|
// 폰은 여전히 프리셋 행을 직접 고칠 수 없다(RLS)
|
|
const blocked = await mobile.from('custom_instructions').update({ prompt: 'hack' }).eq('user_id', userId).eq('builtin_key', 'summarize').select('id')
|
|
expect(blocked.data ?? []).toEqual([])
|
|
})
|
|
|
|
it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => {
|
|
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
|
|
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)
|
|
expect(first.status).toBe('active')
|
|
const listed = must(await mobile.from('devices').select('id,platform,device_name').eq('user_id', userId))
|
|
expect(listed.some((d) => d.device_name === 'IT-DESKTOP')).toBe(true)
|
|
|
|
const deviceId = first.status === 'active' ? first.deviceId : ''
|
|
await mobile.rpc('revoke_device', { target_device_id: deviceId }).throwOnError()
|
|
const after = await checkInDesktopDevice(desktop, userId, 'heartbeat', info, deviceId)
|
|
expect(after.status).toBe('revoked')
|
|
|
|
// 같은 계정으로 다시 로그인하면 새 설치 id로 재등록된다
|
|
const again = await checkInDesktopDevice(desktop, userId, 'signin', info, deviceId)
|
|
expect(again.status).toBe('active')
|
|
})
|
|
})
|