diff --git a/docs/database/detailed-schema.rst b/docs/database/detailed-schema.rst index 1bdb0a85b..5e57d302b 100644 --- a/docs/database/detailed-schema.rst +++ b/docs/database/detailed-schema.rst @@ -558,7 +558,7 @@ * - ``content`` - str - כן - - עד 5,000 תווים. + - עד 20,000 תווים. התקרה מוגדרת פעם אחת ב-``sticky_notes_target.MAX_NOTE_CHARS`` ומיובאת משם ל-webapp, ל-MCP ולתבניות. * - ``position_x`` / ``position_y`` - int - כן diff --git a/docs/testing.rst b/docs/testing.rst index 7b88129c6..4462b87b5 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -156,6 +156,29 @@ Mocking HTTP ב‑github_menu_handler enabled = True # הפיצ'ר נכפה ל-True בזמן טסטים +בדיקות מול מונגו אמיתי +------------------------ + +רוב הבדיקות בריפו רצות מול stub בפייתון טהור, וזה נכון: הן מהירות, אין להן תלות חיצונית, והן מכסות ניתוב, ולידציה, סדר פעולות וקודי שגיאה. + +אבל יש דברים שסטאב **לא יכול** לבדוק — לא כי הוא חלש, אלא כי הם אינם בקוד אלא בהתנהגות של המסד: + +- **אילוץ ייחודיות** — אינדקס ייחודי-חלקי כמו ``one_default_per_user`` הוא מה שסוגר מרוץ בין שתי בקשות מקבילות. רק המסד יכול לדחות, ו-``create_index`` שהחזיר בלי לזרוק אינו ראיה שהאינדקס נוצר. +- **האם אינדקס בשימוש** — רק ``explain`` עונה. סטאב מחזיר תוצאה נכונה גם כששאילתה סורקת את כל האוסף. +- **סמנטיקה של BSON** — ``datetime`` נקטם למילישניות, ובלי ``tz_aware=True`` הוא חוזר נאיבי. השוואה בין נאיבי ל-aware זורקת ``TypeError``, ואם היא עטופה ב-``except`` — הבדיקה שנשענת עליה מפסיקה לרוץ בשקט. +- **צינורות aggregation** — סטאב שנכתב ביד מבין רק את הצורה שנכתבה בו, ולכן שגיאת תחביר אמיתית עוברת אצלו. + +הבדיקות האלה חיות ב-``tests/test_note_boards_mongo.py``. הן **מדלגות** כשאין ``MONGODB_URL`` או כשהשרת אינו נגיש, כך שהרצה מקומית רגילה נשארת מהירה, ורצות באמת ב-CI — הג'וב ``Unit Tests`` מרים ``mongo:6.0`` כשירות ומגדיר ``MONGODB_URL``. + +להרצה מקומית מול שרת אמיתי: + +.. code-block:: bash + + MONGODB_URL='mongodb://127.0.0.1:27017' pytest tests/test_note_boards_mongo.py -v + +.. warning:: + כל הרצה יוצרת מסד עם שם ייחודי משלה (תחילית ``codebot_notes_it_``), וה-teardown מוודא שהשם תואם לתחילית **לפני** ``drop_database``. אל תכוונו את ``MONGODB_URL`` למסד שיש בו נתונים אמיתיים. + כיסוי בדיקות (pytest-cov) -------------------------- diff --git a/docs/user/sticky_notes.rst b/docs/user/sticky_notes.rst index e37204a8e..8824f87e9 100644 --- a/docs/user/sticky_notes.rst +++ b/docs/user/sticky_notes.rst @@ -22,7 +22,7 @@ - מומלץ להצמיד לעוגנים יציבים (כותרת/``id``) כשאפשר, לשמירה לאחר עריכות * - עריכת תוכן - קליק על פתק קיים ועריכת השדה - - התוכן מוגבל ל‑5,000 תווים; אין בחירת צבע בממשק והפתק צהוב אחיד + - התוכן מוגבל ל‑20,000 תווים; אין בחירת צבע בממשק והפתק צהוב אחיד * - הזזה/מיקום מחדש - גרירה קצרה של הפתק למיקום חדש בתצוגה - אם הפתק מעוגן לעוגן יציב – ההצמדה תישמר, והמיקום החזותי יעדכן @@ -44,7 +44,7 @@ מגבלות, פרטיות ואבטחה ----------------------- - מגבלות שימוש: עד 200 פתקים ללוח, ועד 1000 פתקים לכל משתמש — התקרה השנייה חלה על כל הפתקים, גם אלה שעל קבצים. עד לגרסה זו שתיהן היו מתועדות אך נאכפו רק במסלול ה-MCP, וגם שם באופן חלקי; כעת הן נאכפות בשכבת ה-API. משתמשי אדמין פטורים. -- אורך פתק: עד 5,000 תווים. +- אורך פתק: עד 20,000 תווים. מעבר לתקרה השמירה **נדחית** ומוחזר ``content_too_long`` — עד לגרסה זו התוכן נחתך בשקט והתשובה הייתה "נשמר". הממשק מציג חיווי אדום עם מספר התווים בפועל כבר בזמן ההקלדה, לפני שמנסים לשמור. - פרטיות: פתקים נשמרים לחשבון המשתמש בלבד, ואינם מוצגים לאחרים כברירת מחדל. לוחות פתקים @@ -64,7 +64,9 @@ * - מחיקת לוח - הפתקים שעליו עוברים ללוח ברירת המחדל, והממשק מדווח כמה עברו. אם ההעברה לא הושלמה — הלוח **אינו** נמחק, כדי שאף פתק לא יתייתם. * - מצב הפתק - - "מוצמד ללוח" (ברירת המחדל) — הפתק חלק מהמשטח וזז איתו. "צמוד למסך" — נשאר מול העיניים גם כשגוללים בלוח. + - "מוצמד ללוח" (ברירת המחדל) — הפתק חלק מהמשטח וזז איתו. "צמוד למסך" — נשאר מול העיניים גם כשגוללים בלוח. הכפתור ``📌`` בראש הפתק מחליף ביניהם, והוא דלוק רק כשהפתק צמוד למסך. + * - גבולות המשטח + - פתק אינו יוצא מגבולות הלוח. גרירה כלפי מעלה נעצרת בקצה העליון, כי הכותרת של הפתק היא גם ידית הגרירה — פתק שהכותרת שלו מעל הקצה היה נשאר תקוע. בלוח אין עיגון לשורת מקור, כי אין טקסט להיצמד אליו — ולכן גם **בעיית הפתק המיותם אינה קיימת שם**. שום דבר לא זז מתחת לפתק על לוח. @@ -77,6 +79,11 @@ שתי התנהגויות שכדאי להכיר: שורות ``- [ ]`` זהות מזוהות לפי **סדר ההופעה** שלהן, כך שאפשר לסמן כל אחת בנפרד; ושורת ``- [ ]`` בתוך בלוק קוד נספרת גם היא, כי הפתק אינו עובר פרסור מארקדאון מלא. +כשיש בפתק צ'קבוקס אחד לפחות, הפתק עובר לתצוגה שבה התיבות ניתנות ללחיצה. **התצוגה מציגה את כל תוכן הפתק** — שורות שאינן משימה נשארות גלויות כטקסט. לחיצה על טקסט כלשהו בתצוגה מחזירה לעריכה, עם הסמן בתחילת השורה שנלחצה. + +.. note:: + התצוגה היא **חד-כיוונית**: ``content`` הופך ל-HTML, ולעולם לא להפך. הדברים היחידים שכותבים לתוכן הפתק הם תיבת העריכה עצמה וראוט הצ'קבוקס, שמקבל מספר סידורי ומצב רצוי — לא טקסט. לכן רינדור אינו יכול לאבד שורה. + תזכורות בפתקי לוח ~~~~~~~~~~~~~~~~~~ עובדות בדיוק כמו בפתקי קובץ. לחיצה על ההתראה או על הפעמון פותחת את הקישור הקבוע ``/note/``, שמפנה למקום שבו הפתק באמת יושב — קובץ או לוח. diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py index 0edf61c21..f8ab5c0b2 100644 --- a/mcp_server/handlers.py +++ b/mcp_server/handlers.py @@ -14,6 +14,11 @@ import re from typing import Any +# תקרת התוכן מיובאת ולא מוקלדת. עד היום MCP והוובאפ החזיקו כל אחד את המספר +# שלו, כך ששינוי באחד היה משאיר את השני אוכף ערך אחר — ואז אותו פתק נדחה +# בערוץ אחד ומתקבל בשני. +from sticky_notes_target import MAX_NOTE_CHARS as MAX_NOTE_CONTENT + MAX_PER_PAGE = 200 MAX_SEARCH_LIMIT = 100 MAX_COLLECTIONS_LIMIT = 500 @@ -270,7 +275,6 @@ def append_file(backend: Any, user_id: int, *, file_name: str, content: str) -> # -- sticky notes ---------------------------------------------------------- -MAX_NOTE_CONTENT = 5000 MAX_NOTES_PER_SCOPE = 200 MAX_ANCHOR_TEXT = 256 MAX_NOTE_LINE = 1_000_000 diff --git a/sticky_notes_target.py b/sticky_notes_target.py index e5659c683..942c7c51e 100644 --- a/sticky_notes_target.py +++ b/sticky_notes_target.py @@ -28,7 +28,24 @@ class NoteTargetError(ValueError): class NoteQuotaError(ValueError): - """חריגה מתקרת הפתקים, או ספירה שלא הצליחה.""" + """חריגה מתקרת הפתקים, או ספירה שלא הצליחה. + + שני המקרים מיוצגים כתת-מחלקות, ו**זהות השגיאה חיה בסוג ולא בטקסט**. + ``except NoteQuotaError`` ממשיך לתפוס את שניהם, אבל מי שצריך להחזיר קוד + ללקוח תופס את התת-מחלקה ומחזיר ליטרל — ולא ``str(exc)``. + + הסיבה: ``str`` על חריגה הוא הבטחה שאיש לא אוכף. ``raise`` אחד עתידי + שמשרשר נתיב או שאילתה מוצא אותם ללקוח, ו-CodeQL מסמן בדיוק את הזרימה + הזו. סוג של חריגה לא נושא טקסט שאפשר לדלוף. + """ + + +class NoteQuotaUnknown(NoteQuotaError): + """הספירה נכשלה, ולכן אי אפשר לדעת אם יש מקום.""" + + +class NoteQuotaExceeded(NoteQuotaError): + """התקרה מלאה.""" #: מצבי המיקום של פתק. ``surface`` = מעוגן למשטח שמתחתיו (הלוח), ``screen`` = @@ -44,6 +61,20 @@ class NoteQuotaError(ValueError): BOARD_NOTE_MODES = ("surface", "screen") DEFAULT_BOARD_MODE = "surface" +#: תקרת אורך התוכן של פתק, בתווים. +#: +#: **מקור האמת היחיד.** המספר הזה היה מוקלד ידנית ב-15 מקומות — 12 פעמים +#: ב-``webapp/sticky_notes_api.py``, פעם ב-``mcp_server/handlers.py``, פעם +#: ב-JS ופעמיים בתיעוד. משמעות הפיזור הזו היא שכל שינוי ערך משאיר מאחור +#: מסלול אחד שאוכף מספר אחר, ומי שנתקל בו רואה חיתוך בלי הסבר. +#: +#: **למה דווקא 20,000:** זו הנקודה שבה תקרת ההעברה (``max_b64_len=120000`` +#: ב-``_decode_content_b64``) מפסיקה להיות תקרה שנייה נסתרת. 20,000 תווים +#: של אימוג'י — המקרה הכבד ביותר, ארבעה בתים לתו — נותנים 106,668 תווי +#: base64, כלומר עדיין מתחת ל-120,000. מעל זה צריך לשנות גם אותה, ואז אין +#: נקודת עצירה טבעית. +MAX_NOTE_CHARS = 20_000 + def _clean(value: Any) -> str: """מזהה כמחרוזת מנורמלת. ``None``, ``''`` ומחרוזת רווחים — כולם ריקים.""" @@ -163,6 +194,6 @@ def check_note_quota(existing: Optional[int], cap: int, *, is_admin: bool = Fals if is_admin: return if existing is None: - raise NoteQuotaError("note_quota_unknown") + raise NoteQuotaUnknown("note_quota_unknown") if int(existing) >= int(cap): - raise NoteQuotaError("note_quota_exceeded") + raise NoteQuotaExceeded("note_quota_exceeded") diff --git a/tests/sticky-notes-target.test.js b/tests/sticky-notes-target.test.js index 8424aab24..8fbfaea3b 100644 --- a/tests/sticky-notes-target.test.js +++ b/tests/sticky-notes-target.test.js @@ -183,38 +183,247 @@ check('anchored בלי מקור שורות יורד ל-surface', () => { }); -// -- צ'קבוקסים: הפרסור בצד הלקוח -- +// -- צ'קבוקסים: תצוגת התוכן -- // // הזרימה המלאה (fetch, אימות, חזרה אחורה) נבדקת בפייתון ב- -// tests/test_sticky_notes_tasks.py, כי שם היא באמת נכתבת למסד. כאן -// נבדק רק החלק שחי בדפדפן: זיהוי השורות והמצב שלהן. +// tests/test_sticky_notes_tasks.py, כי שם היא באמת נכתבת למסד. כאן נבדק +// מה שחי בדפדפן: מה ``_syncTaskView`` מרנדר, ומה הוא **לא** נוגע בו. +// +// למה DOM מדומה ולא בדיקת פונקציית עזר טהורה: הגרסה הקודמת בדקה +// ``_parseTasks``, שהחזירה רק את שורות המשימה. היא עברה בהצלחה בזמן +// שהתצוגה הסתירה 400 שורות טקסט — כי היא בדקה את המסננת, לא את מה +// שהמשתמש רואה. הבדיקה חייבת לגעת במה שנשבר. + +/** DOM מינימלי — בדיוק מה ש-``_syncTaskView`` ו-``_enterEditAt`` נוגעים בו. */ +class FakeEl { + constructor(tag) { + this.tagName = tag; this.children = []; this.dataset = {}; this.style = {}; + this.hidden = false; this._text = ''; this._attrs = {}; this._classes = new Set(); + this.classList = { + add: (c) => this._classes.add(c), + remove: (c) => this._classes.delete(c), + contains: (c) => this._classes.has(c), + toggle: (c, on) => { if (on) this._classes.add(c); else this._classes.delete(c); }, + }; + } + set className(v) { this._classes = new Set(String(v || '').split(/\s+/).filter(Boolean)); } + get className() { return [...this._classes].join(' '); } + appendChild(c) { this.children.push(c); c.parentNode = this; return c; } + // ``Element.remove`` קיים בכל דפדפן, והקוד קורא לו מאחורי guard. בלי + // מימוש כאן ה-guard מדלג בשקט, והבדיקה "החיווי נעלם" עוברת סתם. + remove() { + const p = this.parentNode; + if (p) { p.children = p.children.filter((c) => c !== this); this.parentNode = null; } + } + set textContent(v) { this.children = []; this._text = String(v); } + get textContent() { return this.children.length ? this.children.map((c) => c.textContent).join('') : this._text; } + setAttribute(k, v) { this._attrs[k] = String(v); } + getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; } + addEventListener() {} + focus() { FakeEl.focused = this; } + _matches(sel) { return sel.startsWith('.') ? this._classes.has(sel.slice(1)) : this.tagName === sel; } + querySelector(sel) { + for (const c of this.children) { + if (c._matches(sel)) return c; + const deep = c.querySelector(sel); + if (deep) return deep; + } + return null; + } + querySelectorAll(sel) { + const out = []; + for (const c of this.children) { if (c._matches(sel)) out.push(c); out.push(...c.querySelectorAll(sel)); } + return out; + } + getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; } +} + +sandbox.document.createElement = (tag) => new FakeEl(tag); + +/** פתק מדומה עם טקסטריה ותצוגה, כמו ש-``_renderNote`` בונה. */ +function makeNote(content) { + const el = new FakeEl('div'); + el.className = 'sticky-note'; + const ta = new FakeEl('textarea'); + ta.className = 'sticky-note-content'; + ta.value = content; + const view = new FakeEl('div'); + view.className = 'sticky-note-tasks'; + el.appendChild(ta); + el.appendChild(view); + return { el, ta, view }; +} + +const CONTENT = 'כותרת\n- [ ] אחת\nשורת טקסט\n- פריט רגיל\n- [x] שתיים'; + +check('תצוגה: **כל** השורות מרונדרות, לא רק המשימות', () => { + // זה הבאג שמחק 400 שורות מהמסך. הגרסה הקודמת הייתה מרנדרת 2. + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + eq(view.children.length, 5, 'מספר השורות'); +}); + +check('תצוגה: טקסט שאינו משימה נשאר גלוי', () => { + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + eq(view.textContent.includes('שורת טקסט'), true, 'טקסט חופשי'); + eq(view.textContent.includes('- פריט רגיל'), true, 'פריט רשימה שאינו משימה'); +}); + +check('תצוגה: הסידור נספר על שורות משימה בלבד', () => { + // הסידור הוא מה שנשלח לשרת. ספירה על כל השורות הייתה מסמנת שורה אחרת. + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + const boxes = view.querySelectorAll('.sticky-task-box'); + eq(boxes.length, 2, 'מספר תיבות'); + eq(boxes[0].dataset.taskIndex, '0'); + eq(boxes[1].dataset.taskIndex, '1'); + eq(boxes[1].checked, true, '[x] מסומנת'); +}); + +check('תצוגה: X גדולה נחשבת מסומנת', () => { + const { el, view } = makeNote('- [X] בוצע'); + fileMgr._syncTaskView(el); + eq(view.querySelectorAll('.sticky-task-box')[0].checked, true); +}); + +check('תצוגה: תוכן בלי משימות משאיר את הטקסטריה גלויה', () => { + // זה מה ששומר על פתק רגיל בדיוק כפי שהיה + const { el, ta, view } = makeNote('סתם טקסט'); + fileMgr._syncTaskView(el); + eq(view.hidden, true, 'התצוגה מוסתרת'); + eq(ta.hidden, false, 'הטקסטריה גלויה'); +}); + +check('רינדור הוא חד-כיווני: התוכן לא נכתב מהתצוגה', () => { + // **החוק שמעל כל השאר.** ``content → HTML``, לעולם לא חזרה. היחידים + // שרשאים לכתוב ל-content הם הטקסטריה וראוט ``/task``. + const { el, ta } = makeNote(CONTENT); + const before = ta.value; + const saves = []; + const realQueue = fileMgr._queueSave; + fileMgr._queueSave = (...a) => { saves.push(a); }; + try { + fileMgr._syncTaskView(el); + fileMgr._syncTaskView(el, { editing: true }); + fileMgr._syncTaskView(el); + } finally { + fileMgr._queueSave = realQueue; + } + eq(ta.value, before, 'התוכן לא השתנה'); + eq(saves.length, 0, 'לא נשלחה שום שמירה'); +}); + +check('חזרה לעריכה: הסמן נוחת בתחילת השורה שנלחצה', () => { + // בלי זה התצוגה היא דלת חד-כיוונית — אי אפשר להוסיף או למחוק מלל + const { el, ta, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + const row = view.children[2]; // 'שורת טקסט' + fileMgr._enterEditAt(el, parseInt(row.dataset.charOffset, 10)); + eq(ta.hidden, false, 'הטקסטריה חזרה'); + eq(FakeEl.focused, ta, 'הפוקוס עבר לטקסטריה'); + eq(CONTENT.slice(ta.selectionStart, ta.selectionStart + 4), 'שורת', 'מיקום הסמן'); +}); + +check('אורך: התקרה מגיעה מהשרת, לא מוקלדת בלקוח', () => { + // בלי ערך מהשרת אין מספר להשוות אליו — ואז לא מזהירים, במקום להזהיר + // לפי מספר שאולי כבר לא נכון. + const { el } = makeNote('קצר'); + sandbox.window.STICKY_NOTE_MAX_CHARS = undefined; + eq(fileMgr._checkContentLength(el, 'א'.repeat(999999)), false, 'אין תקרה ידועה'); + eq(el.querySelector('.sticky-note-warn'), null, 'אין חיווי'); +}); + +check('אורך: בדיוק בגבול מתקבל, ומעליו מוצג חיווי', () => { + sandbox.window.STICKY_NOTE_MAX_CHARS = 20000; + const { el } = makeNote('קצר'); + + eq(fileMgr._checkContentLength(el, 'קצר'), false, 'תוכן קצר'); + eq(el.querySelector('.sticky-note-warn'), null, 'אין חיווי'); + + // הגבול עצמו חוקי — off-by-one כאן פוסל תוכן תקין + eq(fileMgr._checkContentLength(el, 'א'.repeat(20000)), false, 'בדיוק בגבול'); + eq(el.querySelector('.sticky-note-warn'), null, 'אין חיווי בגבול'); -check('פרסור: רק שורות משימה נספרות', () => { - const tasks = fileMgr._parseTasks('כותרת\n- [ ] אחת\nשורה\n- פריט\n- [x] שתיים'); - eq(tasks.length, 2, 'כמות'); - eq(tasks[0].checked, false, 'ראשונה'); - eq(tasks[1].checked, true, 'שנייה'); + eq(fileMgr._checkContentLength(el, 'א'.repeat(20001)), true, 'תו אחד מעל'); + const warn = el.querySelector('.sticky-note-warn'); + eq(warn !== null, true, 'יש חיווי'); + eq(warn.textContent.includes('20001'), true, 'החיווי מציין את האורך בפועל'); + eq(warn.textContent.includes('20000'), true, 'והתקרה שהשרת נתן'); }); -check('פרסור: X גדולה נחשבת מסומנת', () => { - eq(fileMgr._parseTasks('- [X] בוצע')[0].checked, true); +check('אורך: החיווי מתנקה כשחוזרים מתחת לתקרה', () => { + // כשל שנשאר על המסך אחרי שתוקן משקר בדיוק כמו כשל שלא הוצג + sandbox.window.STICKY_NOTE_MAX_CHARS = 20000; + const { el } = makeNote('קצר'); + + fileMgr._checkContentLength(el, 'א'.repeat(20001)); + eq(el.querySelector('.sticky-note-warn') !== null, true, 'החיווי הופיע'); + eq(el.classList.contains('has-length-error'), true, 'קלאס השגיאה נוסף'); + + eq(fileMgr._checkContentLength(el, 'א'.repeat(100)), false, 'שוב מתחת לתקרה'); + eq(el.querySelector('.sticky-note-warn'), null, 'החיווי נעלם'); + eq(el.classList.contains('has-length-error'), false, 'קלאס השגיאה הוסר'); +}); + +check('אורך: אימוג\'י נספר כמו בפייתון, לא כיחידות UTF-16', () => { + // ``String.length`` סופר יחידות UTF-16 והשרת סופר תווי Unicode. בלי + // ההתאמה, 10,001 אימוג\'ים קיבלו אזהרה על תוכן שהשרת מקבל — פי שניים. + sandbox.window.STICKY_NOTE_MAX_CHARS = 20000; + const { el } = makeNote('קצר'); + const emoji = '🙂'.repeat(10001); // 20,002 יחידות, 10,001 תווים + + eq(fileMgr._checkContentLength(el, emoji), false, 'השרת היה מקבל, ולכן אין אזהרה'); + eq(el.querySelector('.sticky-note-warn'), null, 'אין חיווי'); + + // ומעל התקרה האמיתית — כן מזהירים, עם המספר של פייתון + eq(fileMgr._checkContentLength(el, '🙂'.repeat(20001)), true, 'מעל התקרה'); + eq(el.querySelector('.sticky-note-warn').textContent.includes('20001'), true, 'ספירת קוד-פוינטים'); }); -check('פרסור: הטקסט מנוקה מהסוגריים', () => { - eq(fileMgr._parseTasks(' * [ ] משימה מוזחת')[0].text, 'משימה מוזחת'); +check('אורך: הניסוח תואם לחוזה השרת — העדכון כולו נדחה', () => { + // "מה שמעבר לא יישמר" שיקר: השרת דוחה את כל העדכון, לא חותך זנב + sandbox.window.STICKY_NOTE_MAX_CHARS = 20000; + const { el } = makeNote('קצר'); + fileMgr._checkContentLength(el, 'א'.repeat(20001)); + const text = el.querySelector('.sticky-note-warn').textContent; + eq(text.includes('מה שמעבר'), false, 'לא מבטיח שמירה חלקית'); + eq(text.includes('לקצר'), true, 'אומר מה לעשות'); }); -check('פרסור: הסדר הוא סדר המופע, וזה מה שנשלח לשרת', () => { - // שלוש שורות זהות — האינדקס הוא הדבר היחיד שמבדיל ביניהן - const tasks = fileMgr._parseTasks('- [ ] לבדוק\n- [ ] לבדוק\n- [ ] לבדוק'); - eq(tasks.length, 3); +check('שמירה: עריכה חדשה תוך כדי טיסה שורדת כשל', () => { + // **אובדן תוכן שנמדד בדפדפן.** ``_flushFor`` מוציא את המטען מהתור לפני + // שהוא ממתין לרשת. הקלדה בזמן ההמתנה יצרה רשומה חדשה, וכשל דרס אותה. + const id = 'race1'; + fileMgr._pending.set(id, { content: 'העריכה החדשה' }); + fileMgr._restorePending(id, { content: 'המטען הישן', position: { x: 1, y: 2 } }); + + const after = fileMgr._pending.get(id); + eq(after.content, 'העריכה החדשה', 'החדש מנצח'); + eq(after.position.x, 1, 'והישן ממלא מה שחסר'); +}); + +check('שמירה: מידע טרי מהשרת כן דורס את התור', () => { + // הכיוון ההפוך, ובכוונה: ``prev_updated_at`` שחוזר מ-409 חייב לנצח, + // אחרת הניסיון הבא נדחה שוב על אותה חותמת. שתי כוונות, שתי פונקציות. + const id = 'race2'; + fileMgr._pending.set(id, { content: 'טקסט', prev_updated_at: 'ישן' }); + fileMgr._mergePending(id, { prev_updated_at: 'חדש-מהשרת' }); + + const after = fileMgr._pending.get(id); + eq(after.prev_updated_at, 'חדש-מהשרת', 'המידע הטרי ניצח'); + eq(after.content, 'טקסט', 'והתוכן לא נפגע'); }); -check('פרסור: תוכן בלי משימות מחזיר רשימה ריקה', () => { - // זה מה שמשאיר פתק רגיל בדיוק כפי שהיה — התצוגה נשארת מוסתרת - eq(fileMgr._parseTasks('סתם טקסט').length, 0); - eq(fileMgr._parseTasks('').length, 0); - eq(fileMgr._parseTasks(null).length, 0); +check('שמירה: 400 אינו חוזר לתור, 500 כן', () => { + // הבאג: כל !ok הוחזר ל-pending, וה-auto-flush ניסה שוב לנצח. תוכן + // שנדחה ב-400 לא הופך לתקין — זו לולאה חמה שגם מסתירה את הכשל. + eq(fileMgr._isPermanentFailure(400), true, '400 סופי'); + eq(fileMgr._isPermanentFailure(404), true, '404 סופי'); + eq(fileMgr._isPermanentFailure(409), false, '409 נפתר עם חותמת טרייה'); + eq(fileMgr._isPermanentFailure(429), false, '429 — לנסות שוב מאוחר יותר'); + eq(fileMgr._isPermanentFailure(500), false, '500 זמני'); + eq(fileMgr._isPermanentFailure(undefined), false, 'סטטוס לא ידוע — לא לזרוק מידע'); }); console.log(`\n${passed} עברו, ${failed} נכשלו`); diff --git a/tests/test_note_boards_api.py b/tests/test_note_boards_api.py index b8535441c..bfafedec7 100644 --- a/tests/test_note_boards_api.py +++ b/tests/test_note_boards_api.py @@ -451,3 +451,244 @@ def test_count_failure_is_distinguishable_from_empty(client): board = client.get("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/api/note-boards").get_json()["boards"][0] assert "note_count" not in board + + +# -- ``mode``: הכפתור בלוח נשמר -- +# +# ``mode`` נכתב עד היום רק ביצירה, ולכן כפתור המצב לא יכול היה לשנות כלום +# שנשמר. מה שכן קרה בלחיצה עליו זה שהמיקום נכתב מחדש — כלומר הפתק זז ולא +# שינה מצב. + + +def _board_note(client, **extra): + doc = {"_id": 1, "user_id": 7, "board_id": "b1", "content": "", "mode": "surface"} + doc.update(extra) + client.db.sticky_notes.docs.append(doc) + return doc + + +def test_mode_update_is_persisted_for_board_note(client): + doc = _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "screen"}) + + assert res.status_code == 200, res.get_json() + assert doc["mode"] == "screen" + + +def test_mode_alone_is_enough_to_update(client): + """``mode`` בלבד אינו "אין שדות לעדכון".""" + _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "screen"}) + + assert res.status_code == 200 + assert res.get_json()["ok"] is True + + +def test_invalid_mode_is_rejected(client): + doc = _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "anchored"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "invalid_mode" + assert doc["mode"] == "surface", "הפתק לא השתנה" + + +def test_mode_is_refused_on_a_file_note(client): + """פתק קובץ אינו מקבל ``mode``. + + ``_resolveMode`` בלקוח קורא את ``mode`` **לפני** הסנטינלים, ולכן שדה + כזה על פתק קובץ היה משתלט על מסלול העיגון לשורות המקור. + """ + doc = {"_id": 2, "user_id": 7, "file_id": "f1", "content": "", "scope_id": "s1"} + client.db.sticky_notes.docs.append(doc) + + res = client.put("/api/sticky-notes/note/2", json={"mode": "screen"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "mode_not_supported" + assert "mode" not in doc + + +def test_mode_update_works_through_the_batch_route(client): + """זה המסלול שהלקוח באמת משתמש בו — ``_performSaveBatch``.""" + doc = _board_note(client) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "mode": "screen", "position": {"x": 5, "y": 6}}]}, + ) + + assert res.status_code == 200 + assert res.get_json()["results"][0]["ok"] is True + assert doc["mode"] == "screen" + assert (doc["position_x"], doc["position_y"]) == (5, 6) + + +def test_batch_rejects_invalid_mode_without_touching_the_note(client): + doc = _board_note(client) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "mode": "לא-קיים", "position": {"x": 5, "y": 6}}]}, + ) + + result = res.get_json()["results"][0] + assert result["ok"] is False + assert result["status"] == 400 + assert result["error"] == "invalid_mode" + # הפריט כולו נדחה, ולא "חצי נשמר" + assert doc["mode"] == "surface" + assert "position_x" not in doc + + +# -- תקרת אורך התוכן -- +# +# עד לשינוי הזה השרת אכף אותה ב**חיתוך שקט**: הדבקה של 16,291 תווים נשמרה +# כ-5,000 והתשובה הייתה 200 OK. mcp_server/handlers דחה כבר אז; הוובאפ היה +# החריג, ולכן אותו פתק התקבל בערוץ אחד ונחתך בשני. + +import base64 + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +def test_the_limit_has_a_single_source(client): + """שלושת הצרכנים מייבאים את אותו מספר, לא מקלידים אותו.""" + from mcp_server import handlers + from sticky_notes_target import MAX_NOTE_CHARS + from webapp import sticky_notes_api + + assert sticky_notes_api.MAX_NOTE_CHARS == MAX_NOTE_CHARS + assert handlers.MAX_NOTE_CONTENT == MAX_NOTE_CHARS + + +def test_the_javascript_holds_no_hardcoded_limit(): + """הלקוח מקבל את התקרה מהשרת. + + מספר בצד הלקוח היה מקור אמת שני, ופער בינו לבין השרת נראה למשתמש + כחיתוך בלי הסבר. הבדיקה נופלת אם מישהו יחזיר ליטרל. + """ + from pathlib import Path + + js = (Path(__file__).resolve().parent.parent / "webapp/static/js/sticky-notes.js").read_text(encoding="utf-8") + assert "window.STICKY_NOTE_MAX_CHARS" in js, "הלקוח אינו קורא את הערך מהשרת" + assert "MAX_NOTE_CHARS = 5000" not in js + assert "MAX_NOTE_CHARS = 20000" not in js + + +def test_content_at_the_limit_is_accepted(client): + """הגבול עצמו חוקי — off-by-one כאן פוסל תוכן תקין.""" + from sticky_notes_target import MAX_NOTE_CHARS + + client.db.note_boards.docs.append({"_id": 1, "user_id": 7, "name": "לוח", "is_default": True}) + text = "א" * MAX_NOTE_CHARS + + res = client.post("/api/sticky-notes/board/1", json={"content_b64": _b64(text)}) + + assert res.status_code == 201, res.get_json() + assert len(client.db.sticky_notes.docs[-1]["content"]) == MAX_NOTE_CHARS + + +def test_content_over_the_limit_is_rejected_not_truncated(client): + """**זה הבאג.** חיתוך שקט שמוחזר כ-200 הוא מחיקת מידע.""" + from sticky_notes_target import MAX_NOTE_CHARS + + client.db.note_boards.docs.append({"_id": 1, "user_id": 7, "name": "לוח", "is_default": True}) + before = len(client.db.sticky_notes.docs) + + res = client.post("/api/sticky-notes/board/1", json={"content_b64": _b64("א" * (MAX_NOTE_CHARS + 1))}) + + assert res.status_code == 400 + body = res.get_json() + assert body["error"] == "content_too_long" + assert body["max"] == MAX_NOTE_CHARS, "הלקוח צריך לדעת מה התקרה" + assert len(client.db.sticky_notes.docs) == before, "לא נוצר פתק חתוך" + + +def test_update_over_the_limit_leaves_the_note_untouched(client): + from sticky_notes_target import MAX_NOTE_CHARS + + doc = {"_id": 1, "user_id": 7, "board_id": "b1", "content": "המקורי", "mode": "surface"} + client.db.sticky_notes.docs.append(doc) + + res = client.put("/api/sticky-notes/note/1", json={"content_b64": _b64("א" * (MAX_NOTE_CHARS + 1))}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "content_too_long" + assert doc["content"] == "המקורי", "התוכן הקיים לא נדרס בגרסה חתוכה" + + +def test_batch_rejects_the_item_without_touching_the_note(client): + from sticky_notes_target import MAX_NOTE_CHARS + + doc = {"_id": 1, "user_id": 7, "board_id": "b1", "content": "המקורי"} + client.db.sticky_notes.docs.append(doc) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "content_b64": _b64("א" * (MAX_NOTE_CHARS + 1))}]}, + ) + + result = res.get_json()["results"][0] + assert result["status"] == 400 + assert result["error"] == "content_too_long" + assert doc["content"] == "המקורי" + + +# -- דלף מידע דרך טקסט של חריגה (CodeQL code-scanning/620) -- + + +def test_exception_text_never_reaches_the_client(client, monkeypatch): + """טקסט של חריגה לא יוצא ללקוח, גם כשהיא נושאת פרט פנימי. + + ``str(exc)`` בתשובה הוא דלף ממתין: הוא נכון היום כי כל ה-``raise`` + מעבירים ליטרל, אבל שום דבר לא אוכף את זה. הבדיקה מזריקה חריגה שנושאת + נתיב פנימי ומוודאת שהוא **אינו** יוצא. + """ + import sticky_notes_target + + secret = "failed at /srv/app/secrets/db.conf line 42" + + def _leaky(*_a, **_k): + raise sticky_notes_target.NoteQuotaError(secret) + + # ``check_note_quota`` מיובאת בתוך הפונקציה, ולכן מחליפים אותה במקור + monkeypatch.setattr(sticky_notes_target, "check_note_quota", _leaky) + client.db.note_boards.docs.append({"_id": 1, "user_id": 7, "name": "לוח", "is_default": True}) + + res = client.post("/api/sticky-notes/board/1", json={"content": "שלום"}) + + body = str(res.get_json()) + assert "secrets" not in body, f"דלף פרט פנימי: {body}" + assert "/srv" not in body + assert secret not in body + + +def test_quota_errors_map_to_codes_by_type_not_by_text(client, monkeypatch): + """הקוד שמוחזר נגזר מ**סוג** החריגה, ולא מהטקסט שלה. + + לכן גם חריגה שהטקסט שלה שונה לגמרי עדיין מקבלת את הקוד הנכון — וגם + ההפך: טקסט לא יכול להפוך לקוד. + """ + import sticky_notes_target + + client.db.note_boards.docs.append({"_id": 1, "user_id": 7, "name": "לוח", "is_default": True}) + + for exc_cls, expected in ( + (sticky_notes_target.NoteQuotaUnknown, "note_quota_unknown"), + (sticky_notes_target.NoteQuotaExceeded, "note_quota_exceeded"), + ): + def _raise(*_a, _cls=exc_cls, **_k): + raise _cls("טקסט אחר לגמרי שאסור שיצא") + + monkeypatch.setattr(sticky_notes_target, "check_note_quota", _raise) + res = client.post("/api/sticky-notes/board/1", json={"content": "שלום"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == expected + assert "טקסט אחר" not in str(res.get_json()) diff --git a/tests/test_note_boards_mongo.py b/tests/test_note_boards_mongo.py new file mode 100644 index 000000000..634afae70 --- /dev/null +++ b/tests/test_note_boards_mongo.py @@ -0,0 +1,250 @@ +"""בדיקות שרצות מול **מונגו אמיתי**, ולא מול stub. + +מתי הן רצות: כש-``MONGODB_URL`` מוגדר והשרת נענה. ב-CI זה תמיד — הג'וב +``Unit Tests`` מרים ``mongo:6.0`` כשירות (ראו ``.github/workflows/ci.yml``). +מקומית הן מדלגות, כדי שהרצה רגילה תישאר מהירה. + +**למה הן חייבות להתקיים בנפרד מבדיקות ה-stub** + +יש דברים שסטאב לא יכול לבדוק — לא כי הוא לא מספיק טוב, אלא כי הם **אינם +בקוד**. הם התנהגות של המסד: + +``one_default_per_user`` הוא אינדקס ייחודי-חלקי, והוא הדבר היחיד שסוגר את +המרוץ שבו שתי בקשות מקבילות מגלות שאין לוח ברירת מחדל ושתיהן יוצרות אחד. +הגנת קוד לבדה לא מספיקה שם — **המסד חייב לדחות**. אבל ה-stub שבבדיקות +מגדיר ``create_index`` שמחזיר ``None``, ויצירת האינדקס בפרודקשן עטופה +בשלוש שכבות של ``except Exception: pass``. כלומר: אם האינדקס לעולם לא +נוצר, **כל בדיקות ה-stub עוברות** והמרוץ פתוח לרווחה. + +זה בדיוק הדפוס מ-``amir-bug-patterns``: כתיבה שהכשל שלה נבלע, ודיווח +הצלחה שנשען עליה. + +**בטיחות מחיקה:** כל הרצה עובדת על מסד עם שם ייחודי משלה, וה-teardown +מוודא שהשם תואם לתחילית הצפויה לפני ``drop_database``. מסד שלא נוצר כאן +לא נמחק כאן. +""" + +from __future__ import annotations + +import os +import uuid +from datetime import datetime, timezone + +import pytest + +pymongo = pytest.importorskip("pymongo") + +from pymongo.errors import DuplicateKeyError, ServerSelectionTimeoutError # noqa: E402 + +#: תחילית מסדי הבדיקה. ה-teardown מוחק **רק** מסד שמתחיל בה. +_TEST_DB_PREFIX = "codebot_notes_it_" + +_MONGO_URL = os.environ.get("MONGODB_URL", "").strip() + + +def _server_is_reachable(url: str) -> bool: + """האם יש שרת בקצה השני. בלי זה הבדיקות היו נתלות עד timeout ארוך.""" + try: + client = pymongo.MongoClient(url, serverSelectionTimeoutMS=2000, tz_aware=True, tzinfo=timezone.utc) + client.admin.command("ping") + client.close() + return True + except (ServerSelectionTimeoutError, Exception): + return False + + +pytestmark = pytest.mark.skipif( + not _MONGO_URL or not _server_is_reachable(_MONGO_URL), + reason="דורש MONGODB_URL עם שרת מונגו נגיש (קיים ב-CI, לא בהכרח מקומית)", +) + + +@pytest.fixture +def mongo_db(): + """מסד חד-פעמי, עם ``tz_aware=True`` בדיוק כמו בפרודקשן. + + ``tz_aware`` אינו פרט טכני: בלעדיו pymongo מחזיר ``datetime`` נאיבי, + וההשוואה שמחליטה על 409 (``prev_dt < note['updated_at']``) זורקת + ``TypeError`` — שנבלע ב-``except Exception``. כלומר בדיקת הקונקרנטיות + הייתה מפסיקה לרוץ בשקט. יש על כך בדיקה בהמשך הקובץ. + """ + name = f"{_TEST_DB_PREFIX}{uuid.uuid4().hex[:12]}" + client = pymongo.MongoClient(_MONGO_URL, tz_aware=True, tzinfo=timezone.utc) + db = client[name] + try: + yield db + finally: + # סורג בטיחות: מוחקים רק מסד שנוצר כאן + assert name.startswith(_TEST_DB_PREFIX), f"סירוב למחוק מסד שאינו של הבדיקות: {name}" + try: + client.drop_database(name) + finally: + client.close() + + +@pytest.fixture +def indexed_db(mongo_db, monkeypatch): + """מריץ את ``_ensure_indexes`` האמיתי מול המסד הזמני.""" + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "get_db", lambda: mongo_db) + # הדגלים שמונעים בנייה כפולה — מאפסים כדי שהבנייה באמת תרוץ + monkeypatch.setattr(sticky_notes_api, "_INDEX_READY", False, raising=False) + monkeypatch.setattr(sticky_notes_api, "_INDEX_CACHE_LAST_CHECK", 0.0, raising=False) + # ...וגם את הדגל **המשותף** ברדיס. + # + # ``_ensure_indexes`` יוצא מוקדם גם על ``_cache_flag_ready()``, שקורא דגל + # מ-``cache_manager``. ב-CI רדיס פעיל (``REDIS_URL`` מוגדר בג'וב), ולכן דגל + # שנשאר מהרצה קודמת היה הופך את הבנייה ל-no-op — והבדיקות כאן היו נופלות + # על מסד ריק, מסיבה שאין לה שום קשר לקוד שנבדק. + # + # אירוניה קטנה: איפוס ``_INDEX_CACHE_LAST_CHECK`` ל-0 דווקא **פותח** את + # הדלת לקריאת הקאש, כי הוא מבטל את חלון 30 השניות. שוחזר בפועל — בלי + # השורה הבאה נוצרו אפס אינדקסים. + monkeypatch.setattr(sticky_notes_api, "_cache_flag_ready", lambda: False, raising=False) + + sticky_notes_api._ensure_indexes() + return mongo_db + + +# ---------- האינדקס שסוגר את המרוץ ---------- + + +def test_the_unique_partial_index_is_actually_created(indexed_db): + """``create_index`` שהחזיר בלי לזרוק אינו ראיה שהאינדקס קיים. + + זו הבדיקה היחידה בריפו שקוראת את האינדקס בחזרה מהמסד. עד היום היצירה + עטופה ב-``except Exception: pass``, ולכן כשל היה שקט לחלוטין. + """ + info = indexed_db.note_boards.index_information() + + assert "one_default_per_user" in info, f"האינדקס לא נוצר. קיימים: {sorted(info)}" + spec = info["one_default_per_user"] + assert spec.get("unique") is True, f"האינדקס אינו ייחודי: {spec}" + assert spec.get("partialFilterExpression") == {"is_default": True}, ( + f"ה-partialFilterExpression אינו כמצופה: {spec.get('partialFilterExpression')}" + ) + + +def test_a_second_default_board_is_rejected_by_the_database(indexed_db): + """**זה כל תפקידו של האינדקס.** + + שתי בקשות מקבילות יכולות שתיהן לגלות שאין לוח ברירת מחדל. הקוד לבדו + לא יכול למנוע את זה — רק המסד יכול, ורק אם האינדקס באמת שם. + """ + boards = indexed_db.note_boards + boards.insert_one({"user_id": 7, "name": "לוח עבודה", "is_default": True}) + + with pytest.raises(DuplicateKeyError): + boards.insert_one({"user_id": 7, "name": "עוד ברירת מחדל", "is_default": True}) + + assert boards.count_documents({"user_id": 7, "is_default": True}) == 1 + + +def test_the_index_does_not_block_ordinary_boards(indexed_db): + """האילוץ חל רק על ``is_default: True``. + + בלי ה-``partialFilterExpression`` האינדקס היה ייחודי על ``user_id`` + לבדו — כלומר משתמש היה יכול להחזיק **לוח אחד בסך הכל**. תקלה חמורה + בהרבה מזו שהוא בא למנוע, ולכן היא נבדקת במפורש. + """ + boards = indexed_db.note_boards + boards.insert_one({"user_id": 7, "name": "ברירת מחדל", "is_default": True}) + + boards.insert_one({"user_id": 7, "name": "לוח שני", "is_default": False}) + boards.insert_one({"user_id": 7, "name": "לוח שלישי"}) # בלי השדה כלל + + assert boards.count_documents({"user_id": 7}) == 3 + + +def test_another_user_may_have_their_own_default(indexed_db): + """האילוץ הוא לכל משתמש, לא גלובלי.""" + boards = indexed_db.note_boards + boards.insert_one({"user_id": 7, "name": "של שבע", "is_default": True}) + boards.insert_one({"user_id": 99, "name": "של תשעים ותשע", "is_default": True}) + + assert boards.count_documents({"is_default": True}) == 2 + + +# ---------- אינדקסים של הפתקים ---------- + + +def test_board_notes_index_exists_and_is_used(indexed_db): + """אינדקס שקיים ואינו בשימוש שווה לאינדקס שלא קיים. + + ``explain`` הוא הדרך היחידה לדעת. סטאב מחזיר תוצאה נכונה גם כששאילתה + סורקת את כל האוסף. + """ + notes = indexed_db.sticky_notes + assert "user_board_idx" in notes.index_information() + + notes.insert_many([{"user_id": 7, "board_id": f"b{i}", "content": "x"} for i in range(50)]) + plan = notes.find({"user_id": 7, "board_id": "b3"}).explain() + + stage = plan["queryPlanner"]["winningPlan"] + flat = str(stage) + assert "IXSCAN" in flat, f"השאילתה סורקת את כל האוסף: {flat[:300]}" + assert "user_board_idx" in flat, f"נבחר אינדקס אחר: {flat[:300]}" + + +# ---------- סמנטיקה של BSON שהסטאב לא רואה ---------- + + +def test_updated_at_round_trips_timezone_aware(mongo_db): + """בלי ``tz_aware`` ההשוואה שמחליטה על 409 זורקת TypeError — ונבלעת. + + ה-stub מחזיק אובייקט ``datetime`` של פייתון, ולכן הוא **תמיד** aware. + רק מונגו אמיתי יכול להראות את זה. + """ + coll = mongo_db.sticky_notes + written = datetime.now(timezone.utc) + coll.insert_one({"_id": 1, "updated_at": written}) + + read_back = coll.find_one({"_id": 1})["updated_at"] + + assert read_back.tzinfo is not None, "מונגו החזיר datetime נאיבי — ההשוואה ל-409 תזרוק" + # וההשוואה עצמה, זו שרצה בפועל ב-``update_note``, לא זורקת + assert (written < read_back) in (True, False) + + +def test_mongo_truncates_datetime_to_milliseconds(mongo_db): + """BSON שומר מילישניות, פייתון יוצר מיקרו-שניות. + + התיעוד של ההתנהגות הזו הוא הערך של הבדיקה: התשובה ל-API מחזירה את + הערך **שבזיכרון** (עם מיקרו-שניות), בעוד שהמסד מחזיק ערך קטום. שתי + חותמות שנראות זהות אינן בהכרח שוות. + """ + coll = mongo_db.sticky_notes + written = datetime(2026, 8, 21, 6, 28, 12, 690976, tzinfo=timezone.utc) + coll.insert_one({"_id": 1, "updated_at": written}) + + read_back = coll.find_one({"_id": 1})["updated_at"] + + assert read_back.microsecond == 690000, f"ציפיתי לקטימה למילישניות, קיבלתי {read_back.microsecond}" + assert read_back < written, "הערך שבמסד קטן או שווה לזה שנשלח" + + +# ---------- ה-aggregation של מוני הפתקים ---------- + + +def test_note_count_aggregation_runs_on_real_mongo(indexed_db): + """ה-stub מבין רק את הצורה המדויקת שכתבתי ביד. + + צינור עם שגיאת תחביר אמיתית היה עובר שם ונופל רק בפרודקשן. + """ + notes = indexed_db.sticky_notes + notes.insert_many([ + {"user_id": 7, "board_id": "b1"}, + {"user_id": 7, "board_id": "b1"}, + {"user_id": 7, "board_id": "b2"}, + {"user_id": 99, "board_id": "b1"}, # משתמש אחר — לא נספר + {"user_id": 7, "file_id": "f1"}, # פתק קובץ — אין לו board_id + ]) + + rows = list(notes.aggregate([ + {"$match": {"user_id": 7, "board_id": {"$in": ["b1", "b2"]}}}, + {"$group": {"_id": "$board_id", "n": {"$sum": 1}}}, + ])) + + counts = {row["_id"]: row["n"] for row in rows} + assert counts == {"b1": 2, "b2": 1} diff --git a/webapp/app.py b/webapp/app.py index e95e47eb6..aa06d3a96 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -15,6 +15,10 @@ import uuid import inspect import socket + +# תקרת אורך פתק, לשימוש בתבניות. ייבוא ישיר ובלי fallback — ערך מקומי היה +# מחזיר מספר שני בצד השרת, בדיוק מה שהאיחוד בא למנוע. +from sticky_notes_target import MAX_NOTE_CHARS as MAX_NOTE_CHARS_FOR_TEMPLATES from datetime import datetime, timezone from functools import wraps, lru_cache from types import SimpleNamespace @@ -2070,6 +2074,9 @@ def _normalize_custom_theme_doc(doc: dict, *, force_active: bool = False) -> dic 'static_version': static_ver, # קישור לתיעוד (לשימוש בתבניות) 'documentation_url': DOCUMENTATION_URL, + # תקרת אורך פתק — מגיעה ל-JS מכאן ולא מוקלדת שם. בלי זה היו שני + # מספרים שמסונכרנים בתקווה, ופער ביניהם נראה למשתמש כחיתוך בלי הסבר. + 'max_note_chars': MAX_NOTE_CHARS_FOR_TEMPLATES, # External uptime config for templates (non-sensitive only) 'uptime_provider': UPTIME_PROVIDER, 'uptime_status_url': UPTIME_STATUS_URL, diff --git a/webapp/static/css/note-boards.css b/webapp/static/css/note-boards.css index a83235177..8b66f8571 100644 --- a/webapp/static/css/note-boards.css +++ b/webapp/static/css/note-boards.css @@ -1,10 +1,18 @@ /* לוחות פתקים — רשימה ומשטח. + * + * ``hidden`` מול ``display`` של המחבר: הדפדפן מגדיר ``[hidden]{display:none}`` + * במקור ה-user-agent, וכל הכרזת ``display`` של המחבר מנצחת אותו — גם בלי + * ספציפיות גבוהה. ``.board-empty`` הגדיר ``display:flex``, ולכן הכיתוב + * "הלוח ריק" הוצג גם כשהיו פתקים על הלוח. הכלל הבא מחזיר ל-``hidden`` את + * המשמעות שלו בכל הקובץ, ולא רק במקום היחיד שנתפס. * * רשת הנקודות היא CSS טהור בכוונה: אפס נכסים, אפס ווטרמארק, ואפס בייטים * להוריד. היא גם מקבלת את צבע ערכת הנושא הפעילה אוטומטית, מה שאיור סטטי * לא היה עושה. */ +[hidden] { display: none !important; } + /* ---------- רשימת הלוחות ---------- */ .boards-page { @@ -97,14 +105,21 @@ /* ---------- משטח הלוח ---------- */ +/* עמוד המשטח מנצל את כל הרוחב ורוב הגובה. ברשימת הלוחות עמודה צרה + היא הנכונה, אבל משטח עבודה שמוגבל ל-1100px משאיר שוליים מתים משני + הצדדים ועוד 4rem ריקים מתחתיו. */ +.boards-page.is-board-surface { + max-width: none; + padding: .75rem .75rem 1rem; +} + .board-surface-wrap { position: relative; - min-height: calc(100vh - 140px); } .board-surface { position: relative; /* פתק במצב surface הוא absolute יחסית לזה */ - min-height: calc(100vh - 160px); + min-height: calc(100vh - 130px); border-radius: 16px; overflow: auto; border: 1px solid var(--glass-border, rgba(0, 0, 0, .08)); @@ -168,7 +183,6 @@ .board-msg.is-error { background: rgba(220, 53, 69, .12); color: #b02a37; } .board-msg.is-info { background: rgba(103, 126, 234, .12); } -.board-msg[hidden] { display: none; } @media (prefers-color-scheme: dark) { .board-surface { diff --git a/webapp/static/css/sticky-notes.css b/webapp/static/css/sticky-notes.css index 7c44516ad..5e6eb72c2 100644 --- a/webapp/static/css/sticky-notes.css +++ b/webapp/static/css/sticky-notes.css @@ -24,8 +24,11 @@ background: rgba(0,0,0,.06); } .sticky-note-drag{ width: 100%; height: 16px; cursor: move; touch-action: none; } -.sticky-note-actions{ display:flex; gap:6px; } -.sticky-note-btn{ border:none; background: transparent; cursor: pointer; font-size: 16px; transition: background-color .18s ease, color .18s ease; } +/* מרווח בין הכפתורים: הם קטנים, וצמודים זה לזה קשה לפגוע בהם באצבע. + ההגדלה היא במרווח ובריפוד אופקי בלבד — גובה הכותרת לא משתנה, ולכן + ``calc(100% - 42px)`` של גוף הפתק נשאר נכון. */ +.sticky-note-actions{ display:flex; gap:10px; } +.sticky-note-btn{ border:none; background: transparent; cursor: pointer; font-size: 16px; padding: 0 3px; min-width: 22px; transition: background-color .18s ease, color .18s ease; } .sticky-note-pin{ opacity:.8; } .sticky-note-pin:hover{ opacity:1; } .sticky-note-pin.is-active{ @@ -75,26 +78,40 @@ /* ---------- צ'קבוקסים בתוך פתק ---------- */ +/* ``flex: 1`` לא עשה כלום — ``.sticky-note`` אינו קונטיינר flex — ולכן + לתצוגה לא היה גובה משלה כלל. עכשיו היא בדיוק אותו ארגז כמו ה-textarea + שהיא מחליפה, כולל גלילה פנימית: פתק שגדל לבד מזיז את מה שסביבו. */ .sticky-note-tasks { - flex: 1; - overflow-y: auto; /* גלילה בתוך הפתק, לא גדילה אוטומטית: פתק - שגדל לבד מזיז את מה שסביבו ושובר סידור ידני */ - padding: .35rem .5rem; - font-size: .9rem; - line-height: 1.5; + width: 100%; + height: calc(100% - 42px); + overflow-y: auto; + padding: 8px; + font-size: 14px; + line-height: 1.35; + color: #222; + cursor: text; /* הרמז שאפשר ללחוץ ולחזור לעריכה */ + white-space: pre-wrap; + /* ``word-break: break-word`` הוצא משימוש ב-CSS Text 3 (csswg-drafts#4087) + ו-stylelint מסמן אותו. ``overflow-wrap: anywhere`` הוא התחליף שמשמר + את ההתנהגות המדויקת, כולל ההשפעה על הגודל הפנימי המינימלי. */ + overflow-wrap: anywhere; } .sticky-note-tasks[hidden] { display: none; } +.sticky-note.is-minimized .sticky-note-tasks{ display:none; } + +/* שורה שאינה משימה — הטקסט הרגיל של הפתק. היא נשארת גלויה, וזה כל + ההבדל בין "יש כאן צ'קבוקס" לבין "כל שאר הפתק נעלם". */ +.sticky-task-line { min-height: 1.35em; } .sticky-task { display: flex; align-items: flex-start; gap: .4rem; - cursor: pointer; padding: .1rem 0; } -.sticky-task-box { margin-top: .25rem; flex: 0 0 auto; } +.sticky-task-box { margin-top: .25rem; flex: 0 0 auto; cursor: pointer; } .sticky-task-box:checked + .sticky-task-text { text-decoration: line-through; @@ -110,3 +127,18 @@ outline: 2px solid #dc3545; outline-offset: 1px; } + +/* חיווי אורך — התקרה נאכפת בשרת בחיתוך שקט, ולכן היא חייבת להיראות כאן */ +.sticky-note-warn { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 4px 8px; + font-size: 12px; line-height: 1.3; + background: #dc3545; color: #fff; +} + +/* כשל שמירה — עד היום הוא היה ב-console בלבד */ +.sticky-note.has-save-error, +.sticky-note.has-length-error { + outline: 2px solid #dc3545; + outline-offset: 1px; +} diff --git a/webapp/static/js/sticky-notes.js b/webapp/static/js/sticky-notes.js index 8e5a9a3bc..40b041758 100644 --- a/webapp/static/js/sticky-notes.js +++ b/webapp/static/js/sticky-notes.js @@ -135,6 +135,22 @@ // לבין פתק ישן שעדיין לא עבר מיגרציה ל־[data-source-line]. const FLOATING_SENTINEL = '__floating__'; const AUTO_SAVE_DEBOUNCE_MS = 500; + // תקרת התוכן מגיעה **מהשרת**, דרך ``window.STICKY_NOTE_MAX_CHARS`` + // שהתבנית מזריקה מ-``sticky_notes_target.MAX_NOTE_CHARS``. + // + // אין כאן מספר מוקלד בכוונה: מספר בצד הלקוח היה מקור אמת שני, ופער + // בינו לבין השרת נראה למשתמש כחיתוך בלי הסבר. אם השרת לא סיפק ערך — + // פשוט לא מזהירים, במקום להזהיר לפי מספר שאולי כבר לא נכון. + function maxNoteChars(){ + try { + const v = Number(window.STICKY_NOTE_MAX_CHARS); + return (Number.isFinite(v) && v > 0) ? v : 0; + } catch(_) { return 0; } + } + //: שורת משימה: ``- [ ] טקסט`` או ``* [x] טקסט``, עם הזחה כלשהי. + //: אותה תבנית בדיוק כמו ``_TASK_RE`` ב-``sticky_notes_tasks.py`` — שם + //: מתבצעת הכתיבה, וכאן רק התצוגה. סטייה ביניהן הייתה מזיזה סידורים. + const TASK_LINE_RE = /^([ \t]*[-*][ \t]\[)([ xX])(\].*)$/; const AUTO_SAVE_FORCE_INTERVAL_MS = 3500; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h @@ -409,6 +425,32 @@ const box = ev.target; if (box && box.type === 'checkbox') this._onTaskToggle(el, box); }); + // לחיצה בכל מקום בתצוגה שאינו התיבה עצמה מחזירה לעריכה, בשורה + // שנלחצה. זו הדרך היחידה החוצה מהתצוגה — בלעדיה פתק עם צ'קבוקס + // אחד הופך לקריאה-בלבד לתמיד. + const enterFromEvent = (ev) => { + const t = ev.target; + if (t && t.classList && t.classList.contains('sticky-task-box')) return false; + const row = (t && t.closest) ? t.closest('.sticky-task-line') : null; + const offset = row ? parseInt(row.dataset.charOffset, 10) : NaN; + this._enterEditAt(el, Number.isFinite(offset) ? offset : null); + return true; + }; + taskView.addEventListener('click', enterFromEvent); + // נתיב מקלדת. בלעדיו התצוגה היא מלכודת למי שאינו משתמש בעכבר: + // ה-textarea מוסתר, התצוגה לא הייתה ניתנת למיקוד, והמאזין היחיד + // היה click — כלומר אין שום דרך לחזור לעריכה. + taskView.setAttribute('tabindex', '0'); + taskView.setAttribute('role', 'group'); + taskView.setAttribute('aria-label', 'תוכן הפתק — הקישו Enter כדי לערוך'); + taskView.addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return; + // רווח על תיבת סימון הוא סימון, לא כניסה לעריכה + const t = ev.target; + if (t && t.classList && t.classList.contains('sticky-task-box')) return; + ev.preventDefault(); + if (enterFromEvent(ev) === false) return; + }); el.appendChild(header); el.appendChild(textarea); @@ -431,6 +473,7 @@ }); textarea.addEventListener('input', () => { + this._checkContentLength(el, textarea.value); this._queueSave(el, { content: textarea.value }); }); textarea.addEventListener('keydown', (ev) => { @@ -608,11 +651,67 @@ return false; } - _notePayloadFromEl(el){ - const rect = el.getBoundingClientRect(); + // הנקודה שממנה נמדדים ``position.x/y`` של פתק, בקואורדינטות של אזור + // התצוגה. + // + // **זה שורש הבאג "הפתק זז קצת למטה בכל רענון".** השמירה חישבה תמיד + // קואורדינטות של המסמך (``rect + scroll``), אבל פתק במצב ``surface`` + // ממוקם ``absolute`` בתוך הקונטיינר — כלומר הדפדפן קורא בדיוק את + // אותם מספרים ביחס למשטח הלוח. בקובץ שני המרחבים חופפים, כי + // הקונטיינר הוא ה-body והוא לא ממוקם; בלוח המשטח מתחיל כמה מאות + // פיקסלים מתחת לראש המסמך, והפתק ירד בדיוק בהפרש הזה בכל שמירה. + // + // מסלול הקובץ יוצא ביט-זהה: כל ענף שאינו קונטיינר ממוקם מחזיר + // ``-scroll``, ואז ``rect.left - (-scroll.x)`` הוא בדיוק החישוב הישן. + _positionOrigin(el, mode){ const scroll = getScrollOffsets(); - const x = Math.round(rect.left + scroll.x); - const y = Math.round(rect.top + scroll.y); + const docOrigin = { x: -scroll.x, y: -scroll.y }; + let resolved = mode; + if (!resolved) { + const entry = this._getEntry(el); + resolved = this._resolveMode(entry ? entry.data : null); + } + // ``screen`` הוא ``position: fixed`` ונשמר במרחב המסמך, כפי שהיה. + if (resolved === 'screen') return docOrigin; + const parent = this.container; + if (!parent || parent === document.body || parent === document.documentElement) return docOrigin; + try { + const r = parent.getBoundingClientRect(); + // קופסת ה-padding של האב הממוקם, לפני הגלילה הפנימית שלו — + // בדיוק המרחב ש-``left``/``top`` של ילד ``absolute`` נמדדים בו. + return { + x: r.left + (parent.clientLeft || 0) - (parent.scrollLeft || 0), + y: r.top + (parent.clientTop || 0) - (parent.scrollTop || 0) + }; + } catch(_) { return docOrigin; } + } + + // פתק לא יוצא מגבולות המשטח. + // + // בלי זה החלק העליון של הפתק — שהוא גם ידית הגרירה — יכול לעלות מעל + // הקצה, ואז אין שום דרך להחזיר אותו: הגרירה היא לחיצה על הכותרת, + // והכותרת מחוץ למסך. + _clampToSurface(el, x, y, note){ + try { + const parent = this.container; + if (!parent || typeof parent.clientWidth !== 'number' || parent.clientWidth <= 0) { + return { x, y }; + } + let w = (note && note.size && typeof note.size.width === 'number') ? note.size.width : 0; + if (!w) { try { w = Math.round(el.getBoundingClientRect().width) || 0; } catch(_) { w = 0; } } + if (!w) w = 260; + const maxX = Math.max(0, parent.clientWidth - w); + // ציר ה-Y נחסם מלמטה בלבד: המשטח גליל, וגרירה כלפי מטה מגדילה + // אותו — כלומר פתק "רחוק" עדיין נגיש, בניגוד לפתק מעל הקצה. + return { x: clamp(Math.round(x), 0, maxX), y: Math.max(0, Math.round(y)) }; + } catch(_) { return { x, y }; } + } + + _notePayloadFromEl(el, mode){ + const rect = el.getBoundingClientRect(); + const origin = this._positionOrigin(el, mode); + const x = Math.round(rect.left - origin.x); + const y = Math.round(rect.top - origin.y); const w = Math.round(rect.width); const h = Math.round(rect.height); const payload = { position: { x, y }, size: { width: w, height: h } }; @@ -626,13 +725,20 @@ return this.notes.get(id) || null; } - _updatePinButtonState(el, isPinned){ + _updatePinButtonState(el, isActive){ const pinBtn = el ? el.querySelector('.sticky-note-pin') : null; if (!pinBtn) return; - const active = !!isPinned; + const active = !!isActive; pinBtn.classList.toggle('is-active', active); pinBtn.setAttribute('aria-pressed', active ? 'true' : 'false'); - pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; + if (this.boardId) { + // בלוח הכפתור מחליף בין "יושב על הלוח" (ברירת המחדל, כבוי) לבין + // "צף מול המסך" (דלוק). "נעיצה למסמך" היא מושג של קובץ ואין לה + // מובן כאן — ולכן גם אין סיבה שהכפתור יידלק כבר בפתיחה. + pinBtn.title = active ? 'החזר את הפתק ללוח' : 'הצמד את הפתק למסך'; + } else { + pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; + } } // ----- צ'קבוקסים ----- @@ -642,47 +748,114 @@ // שהתצוגה לא תשקר: אם הכתיבה לא נקלטה, הסימון חוזר אחורה ומוצגת // שגיאה. כשל שקט הוא בדיוק מה שהאפיון אוסר. - _parseTasks(text){ - const lines = String(text == null ? '' : text).split('\n'); - const re = /^([ \t]*[-*][ \t]\[)([ xX])(\].*)$/; - const out = []; - lines.forEach((line) => { - const m = re.exec(line); - if (m) out.push({ checked: m[2].toLowerCase() === 'x', text: m[3].slice(1).trim() }); - }); - return out; - } - + // בונה את התצוגה מ**כל** השורות, לא רק משורות המשימה. + // + // הגרסה הראשונה רינדרה רק את שורות הצ'קבוקס והסתירה את ה-textarea. + // התוצאה: פתק עם 400 שורות ושורת משימה אחת נראה כאילו נמחק — כל השאר + // פשוט לא הוצג, ולא הייתה שום דרך לחזור לעריכה. התוכן היה בטקסטאריה + // כל הזמן (נמדד בדפדפן: 5,509 תווים לפני ואחרי, אפס בקשות כתיבה), + // אבל מבחינת המשתמש זה אובדן מידע — ומבחינת התוצאה זה נכון. + // + // **החוק שמעל הפונקציה הזו: רינדור הוא חד-כיווני.** ``content → + // HTML``, לעולם לא חזרה. היחידים שרשאים לכתוב ל-``content`` הם + // ה-textarea עצמו וראוט ``/task`` (סידורי + מצב). על הכלל הזה יש + // בדיקה — ראו ``tests/sticky-notes-target.test.js``. _syncTaskView(el, opts){ try { const view = el.querySelector('.sticky-note-tasks'); const textarea = el.querySelector('.sticky-note-content'); if (!view || !textarea) return; const editing = !!(opts && opts.editing); - const tasks = this._parseTasks(textarea.value); - if (editing || !tasks.length) { + const lines = String(textarea.value == null ? '' : textarea.value).split('\n'); + const hasTask = lines.some((line) => TASK_LINE_RE.test(line)); + if (editing || !hasTask) { view.hidden = true; textarea.hidden = false; return; } view.textContent = ''; - tasks.forEach((task, idx) => { - const label = createEl('label', 'sticky-task'); - const box = createEl('input', 'sticky-task-box'); - box.type = 'checkbox'; - box.checked = task.checked; - box.dataset.taskIndex = String(idx); - const span = createEl('span', 'sticky-task-text'); - span.textContent = task.text; - label.appendChild(box); - label.appendChild(span); - view.appendChild(label); + let taskIndex = 0; + let charOffset = 0; + lines.forEach((line) => { + const m = TASK_LINE_RE.exec(line); + const row = createEl('div', 'sticky-task-line'); + // מיקום התו הראשון של השורה בתוך התוכן — כדי שלחיצה תחזיר + // לעריכה בדיוק בשורה שנלחצה, ולא בתחילת הפתק. + row.dataset.charOffset = String(charOffset); + if (m) { + row.classList.add('sticky-task'); + const box = createEl('input', 'sticky-task-box'); + box.type = 'checkbox'; + box.checked = m[2].toLowerCase() === 'x'; + box.dataset.taskIndex = String(taskIndex); + taskIndex += 1; + const span = createEl('span', 'sticky-task-text'); + span.textContent = m[3].slice(1).trim(); + // התיבה והטקסט הם אחים ולא ``