Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@
## 2026-07-30 - Add window.confirm for destructive actions
**Learning:** Destructive actions like deleting groups and edge relationships previously occurred immediately without user confirmation.
**Action:** Always wrap delete operations with window.confirm() dialogs and ensure corresponding tests successfully mock window.confirm.
## 2024-08-16 - 파괴적 μ•‘μ…˜μ— λŒ€ν•œ μ‚¬μš©μž 확인 μΆ”κ°€
**Learning:** ν…Œμ΄λΈ” νŽΈμ§‘ λͺ¨λ‹¬ λ‚΄ 'ν…Œμ΄λΈ” μ‚­μ œ'와 같은 파괴적인 μ•‘μ…˜μ΄ μ‚¬μš©μž 확인 없이 μ¦‰μ‹œ μ‹€ν–‰λ˜μ–΄, μ˜λ„μΉ˜ μ•Šμ€ 데이터 손싀을 μ΄ˆλž˜ν•  수 μžˆμ—ˆμŠ΅λ‹ˆλ‹€.
**Action:** 파괴적인 μž‘μ—…μ„ μˆ˜ν–‰ν•˜λŠ” λ²„νŠΌμ˜ 이벀트 ν•Έλ“€λŸ¬μ—λŠ” 항상 `window.confirm`을 μ‚¬μš©ν•˜μ—¬ μ‚¬μš©μžμ˜ λͺ…μ‹œμ μΈ 확인을 받도둝 κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- [FE] πŸ›‘οΈ **ν…Œμ΄λΈ” μ‚­μ œ 확인**: ν…Œμ΄λΈ” νŽΈμ§‘ ν™”λ©΄μ—μ„œ μ‚­μ œλ₯Ό μ‹€ν–‰ν•˜κΈ° 전에 ν™•μΈν•˜μ—¬ μ‹€μˆ˜λ‘œ μΈν•œ 데이터 손싀을 μ˜ˆλ°©ν•©λ‹ˆλ‹€.
- [BE] πŸ”’ **Cryptography 50+ λ³΄μ•ˆ 경계 κ°±μ‹ **: `pyproject.toml`κ³Ό 두 hash-locked μš”κ΅¬μ‚¬ν•­ νŒŒμΌμ„ λ™μΌν•œ Cryptography 50+ ν•΄μ„μœΌλ‘œ μ •ν•©ν™”ν•˜μ—¬ PKCS#7 였λ₯˜Β·νƒ€μ΄λ° κ΅¬λΆ„μœΌλ‘œ μΈν•œ CVE-2026-69247 μ™„ν™”λ₯Ό μ‹€μ œ μ„€μΉ˜Β·κ²€μ¦ κ²½λ‘œμ— λ°˜μ˜ν–ˆμŠ΅λ‹ˆλ‹€.
- [FE] ⚑ **검색 λ…Έλ“œ μ°Έμ‘° μ•ˆμ •ν™” 및 순차 μŠ€λƒ…μƒ· 폴링**: 같은 μ •κ·œν™” 검색어와 원본 ν…Œμ΄λΈ” λ°μ΄ν„°μ—λŠ” μž₯μ‹λœ `node.data` μ°Έμ‘°λ₯Ό μž¬μ‚¬μš©ν•˜μ—¬ λ“œλž˜κ·Έ 쀑 λΆˆν•„μš”ν•œ ν•˜μœ„ λ Œλ”λ§κ³Ό 할당을 μ€„μž…λ‹ˆλ‹€. μŠ€λƒ…μƒ· 폴링은 이전 μš”μ²­μ΄ λλ‚œ λ’€μ—λ§Œ λ‹€μŒ μš”μ²­μ„ μ˜ˆμ•½ν•˜λ©°, 선택 λ³€κ²½Β·μ–Έλ§ˆμš΄νŠΈ ν›„ λ„μ°©ν•œ 였래된 성곡 λ˜λŠ” μ‹€νŒ¨ 응닡을 λ¬΄μ‹œν•©λ‹ˆλ‹€.
- [BE] πŸ”’ **곡유 export μ „ 경둜 redaction**: 곡개 share의 SQL / index-design / reversing-spec exportμ—μ„œ μ½”λ©˜νŠΈΒ·`example_value`λ₯Ό μ œκ±°ν•©λ‹ˆλ‹€. λ‹¨μœ„ ν…ŒμŠ€νŠΈλ‘œ λˆ„μΆœμ„ μ°¨λ‹¨ν•©λ‹ˆλ‹€.
Expand Down
1 change: 1 addition & 0 deletions frontend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
### Added
- **ν…Œμ΄λΈ” μ‚­μ œ 확인**: ν…Œμ΄λΈ” νŽΈμ§‘ ν™”λ©΄μ—μ„œ μ‚­μ œν•˜κΈ° 전에 확인창을 ν‘œμ‹œν•˜μ—¬ μ‹€μˆ˜λ‘œ μΈν•œ 데이터 손싀을 μ˜ˆλ°©ν•©λ‹ˆλ‹€.
- **ν…Œμ΄λΈ” 및 컬럼 νŽΈμ§‘ κΈ°λŠ₯**: UI νŒ¨λ„μ„ 톡해 λ…Έλ“œλ₯Ό μ„ νƒν•˜κ³ , ν…Œμ΄λΈ”μ˜ 이름/μ½”λ©˜νŠΈλ₯Ό μˆ˜μ •ν•˜λ©°, μ»¬λŸΌμ„ μΆ”κ°€/μˆ˜μ •/μ‚­μ œν•˜κ±°λ‚˜ ν…Œμ΄λΈ”μ„ μ‚­μ œν•  수 μžˆλŠ” κΈ°λŠ₯ μΆ”κ°€.
- **ν…ŒμŠ€νŠΈ μΆ”κ°€**: ν”„λ‘ νŠΈμ—”λ“œ ν…ŒμŠ€νŠΈ 컀버리지 100% λͺ©ν‘œ 달성을 μœ„ν•΄ `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 λΆ„κΈ° 및 ν•¨μˆ˜ ν…ŒμŠ€νŠΈ μΆ”κ°€ (`cardinality_extra.test.ts` λ“±).
- `.gitignore` νŒŒμΌμ— `coverage/` 폴더λ₯Ό μΆ”κ°€ν•˜μ—¬ λΆˆν•„μš”ν•œ ν…ŒμŠ€νŠΈ μ•„ν‹°νŒ©νŠΈκ°€ μ»€λ°‹λ˜μ§€ μ•Šλ„λ‘ 보완.
Expand Down
12 changes: 6 additions & 6 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"overrides": {
"esbuild": "^0.25.0",
"nanoid": "^3.3.18",
"postcss": "^8.5.18"
}
}
52 changes: 48 additions & 4 deletions frontend/src/App.coverage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ vi.mock('./components/modals', () => ({
<button type="button" data-testid="export-uml" onClick={props.onDownloadUml} />
<button type="button" data-testid="export-mermaid" onClick={props.onDownloadMermaid} />
<button type="button" data-testid="export-dbml" onClick={props.onDownloadDbml} />
<button type="button" data-testid="export-prisma" onClick={props.onDownloadPrisma} />
<button type="button" data-testid="export-csv" onClick={props.onExportDictionaryCsv} />
<button type="button" data-testid="export-md" onClick={props.onExportDictionaryMarkdown} />
<button type="button" data-testid="share-create" onClick={props.onCreateShareLink} />
Expand Down Expand Up @@ -414,8 +415,6 @@ describe('App orchestration coverage', () => {
fireEvent.doubleClick(screen.getByTestId('flow-node'))
fireEvent.click(screen.getByTestId('table-cancel'))
fireEvent.doubleClick(screen.getByTestId('flow-node'))
vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
fireEvent.click(screen.getByTestId('table-delete'))
fireEvent.click(screen.getByTestId('table-delete'))
})

Expand Down Expand Up @@ -456,14 +455,14 @@ describe('App orchestration coverage', () => {
fireEvent.click(screen.getByTestId('card-close'))

fireEvent.click(screen.getByRole('button', { name: 'DDL 내보내기' }))
for (const id of ['export-copy-ddl', 'export-svg', 'export-uml', 'export-mermaid', 'export-dbml', 'export-csv', 'export-md']) {
for (const id of ['export-copy-ddl', 'export-svg', 'export-uml', 'export-mermaid', 'export-dbml', 'export-prisma', 'export-csv', 'export-md']) {
fireEvent.click(screen.getByTestId(id))
}
fireEvent.click(screen.getByTestId('share-create'))
await waitFor(() => expect(screen.getByTestId('share-url')).toHaveTextContent('/api/share/one'))
fireEvent.click(screen.getByTestId('share-copy'))
fireEvent.click(screen.getByTestId('export-close'))
expect(exports.downloadText).toHaveBeenCalledTimes(6)
expect(exports.downloadText).toHaveBeenCalledTimes(7)

fireEvent.click(screen.getByRole('button', { name: '관계 μžλ™ μΆ”λ‘ ' }))
expect(exports.inferRelationships).toHaveBeenCalled()
Expand Down Expand Up @@ -651,6 +650,51 @@ describe('App orchestration coverage', () => {
expect(screen.getByRole('alert')).toHaveTextContent('terminal refresh down')
})

it('ignores terminal refresh completions after the editor effect is cleaned up', async () => {
const projectTwoSnapshots = [
{ schema_snapshot_uuid: 'p2-snapshot', status: 'succeeded', schema_filter: 'p2-only' },
]
let resolveRefresh!: (value: typeof snapshots) => void
let rejectRefresh!: (reason: unknown) => void
let listSnapshotsCall = 0
api.listSnapshots.mockImplementation(() => {
listSnapshotsCall += 1
if (listSnapshotsCall === 1) return Promise.resolve(snapshots)
if (listSnapshotsCall === 2) {
return new Promise((resolve) => { resolveRefresh = resolve })
}
if (listSnapshotsCall === 3 || listSnapshotsCall === 4) {
return Promise.resolve(projectTwoSnapshots)
}
if (listSnapshotsCall === 5) return Promise.resolve(snapshots)
if (listSnapshotsCall === 6) {
return new Promise((_resolve, reject) => { rejectRefresh = reject })
}
return Promise.resolve(snapshots)
})
await renderReadyApp()
await waitFor(() => expect(api.listSnapshots).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'λ‹€μ΄μ–΄κ·Έλž¨' }))
await waitFor(() => expect(screen.getAllByRole('button', { name: 'μ—΄κΈ°' }).length).toBeGreaterThan(0))
fireEvent.click(screen.getAllByRole('button', { name: 'μ—΄κΈ°' })[0]!)
await waitFor(() => expect(resolveRefresh).toBeTypeOf('function'))
fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } })
await waitFor(() => expect(listSnapshotsCall).toBe(4))
fireEvent.click(screen.getByRole('button', { name: 'λ‹€μ΄μ–΄κ·Έλž¨' }))
expect(await screen.findByText('ERD_p2-only_1')).toBeInTheDocument()
await act(async () => resolveRefresh(snapshots))
expect(screen.getByText('ERD_p2-only_1')).toBeInTheDocument()
expect(screen.queryByText('ERD_billing_1')).not.toBeInTheDocument()

fireEvent.click(screen.getByRole('button', { name: 'νŽΈμ§‘κΈ°' }))
fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p1' } })
await waitFor(() => expect(rejectRefresh).toBeTypeOf('function'))
fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } })
await waitFor(() => expect(listSnapshotsCall).toBe(8))
await act(async () => rejectRefresh(new Error('late terminal refresh')))
expect(screen.queryByText(/late terminal refresh/)).not.toBeInTheDocument()
})

it('ignores stale project metadata failures after changing projects', async () => {
let rejectConnections!: (reason: unknown) => void
let rejectSnapshots!: (reason: unknown) => void
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export default function App() {
return;
}

/* v8 ignore next -- after the current response guard, cleanup cannot interleave with this synchronous branch */
if (isCurrent) {
timer = window.setTimeout(poll, 1000);
}
Expand Down Expand Up @@ -862,7 +863,6 @@ export default function App() {

function onDeleteTable() {
if (!editingNode) return;
if (!window.confirm("μ •λ§λ‘œ 이 ν…Œμ΄λΈ”μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")) return;

// Remove the node
setNodes((nds) => nds.filter((n) => n.id !== editingNode.id));
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/modals/EditTableModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ export function EditTableModal({
<div className="row" style={{ gap: 8 }}>
<button
type="button"
onClick={onDeleteTable}
onClick={() => {
if (!window.confirm("μ •λ§λ‘œ 이 ν…Œμ΄λΈ”μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")) return;
onDeleteTable();
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
style={{ color: "#b91c1c", borderColor: "#fca5a5" }}
>
ν…Œμ΄λΈ” μ‚­μ œ
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/modals/ModalCoverage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ describe('modal behavior coverage', () => {
fireEvent.change(screen.getByLabelText('μ œμ•½μ‘°κ±΄ 이름 (Label)'), {
target: { value: 'fk_changed' },
})
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
fireEvent.click(screen.getByRole('button', { name: 'μ‚­μ œ' }))
fireEvent.click(screen.getByRole('button', { name: 'μ‚­μ œ' }))
expect(window.confirm).toHaveBeenCalledWith("이 관계λ₯Ό μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")
fireEvent.click(screen.getByRole('button', { name: 'μ·¨μ†Œ' }))
Expand Down Expand Up @@ -183,7 +184,15 @@ describe('modal behavior coverage', () => {
expect(deleteEditing(tableNode)?.data.columns).toHaveLength(1)

fireEvent.submit(document.getElementById('editTableForm')!)

const confirmSpy = vi.spyOn(window, 'confirm')
confirmSpy.mockReturnValueOnce(false)
fireEvent.click(screen.getByRole('button', { name: 'ν…Œμ΄λΈ” μ‚­μ œ' }))
expect(onDeleteTable).not.toHaveBeenCalled()

confirmSpy.mockReturnValueOnce(true)
fireEvent.click(screen.getByRole('button', { name: 'ν…Œμ΄λΈ” μ‚­μ œ' }))

fireEvent.click(screen.getByRole('button', { name: '볡제' }))
const duplicate = setNodes.mock.calls[2]?.[0] as (nodes: Node<TableNodeData>[]) => Node<TableNodeData>[]
const duplicated = duplicate([tableNode])[1]!
Expand Down Expand Up @@ -245,6 +254,8 @@ describe('modal behavior coverage', () => {
)
expect(screen.getByText('λ“±λ‘λœ 그룹이 μ—†μŠ΅λ‹ˆλ‹€.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'μΆ”κ°€' })).toBeDisabled()
fireEvent.submit(screen.getByRole('button', { name: 'μΆ”κ°€' }).closest('form')!)
expect(onCreate).not.toHaveBeenCalled()

rerender(
<GroupModal
Expand All @@ -264,7 +275,8 @@ describe('modal behavior coverage', () => {
fireEvent.change(screen.getByLabelText('κ·Έλ£Ή 이름'), { target: { value: 'New' } })
fireEvent.click(screen.getAllByRole('button', { name: /^색상 / })[1]!)
fireEvent.click(screen.getByRole('button', { name: 'μΆ”κ°€' }))
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
fireEvent.click(screen.getByRole('button', { name: 'Billing κ·Έλ£Ή μ‚­μ œ' }))
fireEvent.click(screen.getByRole('button', { name: 'Billing κ·Έλ£Ή μ‚­μ œ' }))
expect(window.confirm).toHaveBeenCalledWith("'Billing' 그룹을 μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")
fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } })
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/erd/__tests__/prisma.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,13 @@ describe('exportPrisma', () => {
const edges: Edge[] = [
{ id: 'e1', source: 'invalid', target: '2' },
{ id: 'e2', source: '1', target: '2' },
{ id: 'e3', source: '1', target: '2', sourceHandle: 'invalid-handle' },
Comment thread
seonghobae marked this conversation as resolved.
Resolved
];

const result = exportPrisma(nodes, edges);
expect(result).toContain('model A');
expect(result).toContain('model B');
expect(result).not.toContain('@relation(');
});

it('handles missing is_not_null logic for optional relationships', () => {
Expand Down Expand Up @@ -228,10 +230,19 @@ describe('exportPrisma', () => {
targetHandle: 'tgt-id',
label: '1to1',
},
{
id: 'e2',
source: '2',
target: '1',
sourceHandle: 'src-id',
targetHandle: 'tgt-id',
label: 'profile_identity',
},
];

const result = exportPrisma(nodes, edges);
expect(result).toContain('users_user_id users? @relation("M_1to1", fields: [user_id], references: [id])');
expect(result).toContain('profiles_user_id profiles[] @relation("M_1to1")');
expect(result).toContain('profiles_id profiles? @relation("profile_identity")');
});
});
Loading