package slidestest import ( "context" "net/http" "strconv" "strings" "testing" "amadan.net/oficina/slides/internal/slides/core" ) // newMirrorWithClient is newMirror under a client id of the caller's // choosing, for a test that needs two browsers on one deck. func newMirrorWithClient(t *testing.T, sqid string, client uint64, log [][]byte) *mirror { t.Helper() ctx := context.Background() rt, err := core.New(ctx) if err != nil { t.Fatalf("core.New: %v", err) } h, err := rt.NewDoc(ctx, sqid, client) if err != nil { rt.Close(ctx) t.Fatalf("core.NewDoc: %v", err) } m := &mirror{rt: rt, h: h} t.Cleanup(func() { h.Close(ctx) rt.Close(ctx) }) m.apply(t, log) return m } // TestUpdateWithAnUnknownMarkIsRefused (codex 3): the write gate used // to parse the deck's JSON view after applying a posted update, and the // view tolerates a formatting attribute it does not know by dropping it // from the run — so an update carrying `unknown_mark` parsed perfectly // and was stored, and y-prosemirror 1.3.7 then deleted the whole text // the moment the editor bound to it. The gate now checks the shared // text itself (core.Handle.Check) before storing anything: such an // update is a 422, nothing lands in the log, the row's seq does not // move, and every surface still opens the deck. func TestUpdateWithAnUnknownMarkIsRefused(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) path := createDeck(t, a, cookies, "Integrity") before, seqBefore := getUpdates(t, a, path, 0, cookies) rec := postBody(t, a, path+"/updates", "application/octet-stream", poisonUpdate(t, "unknownmark"), cookies) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("POST %s/updates with a run under unknown_mark: status %d, want 422\n%s", path, rec.Code, rec.Body) } after, seqAfter := getUpdates(t, a, path, 0, cookies) if len(after) != len(before) || seqAfter != seqBefore { t.Errorf("a refused update was stored: %d frames at seq %d, was %d at %d", len(after), seqAfter, len(before), seqBefore) } if got := scalar(t, a, "SELECT seq FROM decks WHERE sqid = ?", sqidOf(path)); got != strconv.FormatInt(seqBefore, 10) { t.Errorf("the row's seq moved to %s on a refused update", got) } stillRenders(t, a, path, "", cookies) // The same deck still takes a well-formed update afterwards: the // refusal touched nothing. log, _ := getUpdates(t, a, path, 0, cookies) m := newMirror(t, sqidOf(path), log) update := m.ops(t, `[{"op":"add_slide","id":"integrityokk","layout":"flow","template":"title"}, {"op":"add_block","slide":"integrityokk","id":"integrityblk","type":"heading","props":{"level":1}, "region":"heading","markdown":"Still **works**"}]`) postUpdate(t, a, path, update, cookies) slide := mustGet(t, a, path+"/slides/integrityokk.html", http.StatusOK, cookies).Body.String() if !strings.Contains(slide, "works") { t.Errorf("the well-formed update after the refusal did not render:\n%s", slide) } } // TestMoveBlockOverTheWireKeepsAConcurrentEdit (codex 4): two browsers // hold the same deck; one moves a block, the other types into that // block; both updates reach the server, in that order. The block's new // text is in the deck, in the moved position — the move wrote an order // key onto the block map rather than deleting the map and inserting a // copy, which is what used to lose the typing. func TestMoveBlockOverTheWireKeepsAConcurrentEdit(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) path := createDeck(t, a, cookies, "Concurrent") log, _ := getUpdates(t, a, path, 0, cookies) setup := newMirror(t, sqidOf(path), log) postUpdate(t, a, path, setup.ops(t, `[ {"op":"add_slide","id":"concurrentsl","layout":"canvas"}, {"op":"add_block","slide":"concurrentsl","id":"concurrentlo","type":"text","markdown":"below", "box":{"x":0.1,"y":0.6,"w":0.3,"h":0.2}}, {"op":"add_block","slide":"concurrentsl","id":"concurrenthi","type":"text","markdown":"above", "box":{"x":0.1,"y":0.1,"w":0.3,"h":0.2}}]`), cookies) // Two browsers, each caught up to the same point, each unaware of // the other's next edit. A different client id each: newMirror // hands out 2, and two writers sharing one id is the one thing a // CRDT cannot forgive. log, _ = getUpdates(t, a, path, 0, cookies) mover := newMirror(t, sqidOf(path), log) typist := newMirrorWithClient(t, sqidOf(path), 3, log) converted := mover.ops(t, `[{"op":"set_slide","id":"concurrentsl","layout":"flow"}]`) typed := typist.ops(t, `[{"op":"set_text","slide":"concurrentsl","id":"concurrentlo","markdown":"below and more"}]`) postUpdate(t, a, path, converted, cookies) postUpdate(t, a, path, typed, cookies) slide := mustGet(t, a, path+"/slides/concurrentsl.html", http.StatusOK, cookies).Body.String() if !strings.Contains(slide, "below and more") { t.Errorf("the concurrent edit was lost across the layout switch:\n%s", slide) } hi, lo := strings.Index(slide, `data-id="concurrenthi"`), strings.Index(slide, `data-id="concurrentlo"`) if hi < 0 || lo < 0 || hi > lo { t.Errorf("the conversion did not put the higher block first (hi at %d, lo at %d):\n%s", hi, lo, slide) } } // TestMoveSlideOverTheWireKeepsAConcurrentEdit (codex 4 at slide // level): the same story one level up. One browser reorders the deck // while another types into the slide being moved; both updates reach // the server. The typing is still there and the strip the server // renders is in the new order, because move_slide writes an `order` // key onto the slide map rather than deleting the map and inserting a // copy — which is what the other client's text was addressing. func TestMoveSlideOverTheWireKeepsAConcurrentEdit(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) path := createDeck(t, a, cookies, "Concurrent slides") log, _ := getUpdates(t, a, path, 0, cookies) setup := newMirror(t, sqidOf(path), log) postUpdate(t, a, path, setup.ops(t, `[ {"op":"add_slide","id":"concurrenta0","layout":"flow"}, {"op":"add_slide","id":"concurrentb0","layout":"flow"}, {"op":"add_slide","id":"concurrentc0","layout":"flow"}, {"op":"add_block","slide":"concurrentc0","id":"concurrenttx","type":"text","markdown":"three"}]`), cookies) log, _ = getUpdates(t, a, path, 0, cookies) mover := newMirror(t, sqidOf(path), log) typist := newMirrorWithClient(t, sqidOf(path), 3, log) moved := mover.ops(t, `[{"op":"move_slide","id":"concurrentc0","after":null}]`) typed := typist.ops(t, `[{"op":"set_text","slide":"concurrentc0","id":"concurrenttx","markdown":"three, edited"}]`) postUpdate(t, a, path, moved, cookies) postUpdate(t, a, path, typed, cookies) slide := mustGet(t, a, path+"/slides/concurrentc0.html", http.StatusOK, cookies).Body.String() if !strings.Contains(slide, "three, edited") { t.Errorf("the concurrent edit was lost across the slide move:\n%s", slide) } strip := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String() var order []int for _, id := range []string{"concurrentc0", "concurrenta0", "concurrentb0"} { i := strings.Index(strip, `data-slide-id="`+id+`"`) if i < 0 { t.Fatalf("slide %s is not in the strip:\n%s", id, strip) } order = append(order, i) } if !(order[0] < order[1] && order[1] < order[2]) { t.Errorf("the strip is not in the moved order (positions %v)", order) } }