github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/notes/notes_test.go (about)

     1  package notes
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"net/http"
     7  	"os"
     8  	"strconv"
     9  	"sync"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/cozy/cozy-stack/model/note"
    14  	"github.com/cozy/cozy-stack/pkg/config/config"
    15  	"github.com/cozy/cozy-stack/pkg/consts"
    16  	"github.com/cozy/cozy-stack/pkg/realtime"
    17  	"github.com/cozy/cozy-stack/tests/testutils"
    18  	"github.com/cozy/cozy-stack/web/errors"
    19  	"github.com/cozy/cozy-stack/web/files"
    20  	webRealtime "github.com/cozy/cozy-stack/web/realtime"
    21  	"github.com/gavv/httpexpect/v2"
    22  	"github.com/labstack/echo/v4"
    23  	"github.com/stretchr/testify/assert"
    24  	"github.com/stretchr/testify/require"
    25  )
    26  
    27  func TestNotes(t *testing.T) {
    28  	if testing.Short() {
    29  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    30  	}
    31  
    32  	var noteID, otherNoteID string
    33  	var version int64
    34  
    35  	config.UseTestFile(t)
    36  	testutils.NeedCouchdb(t)
    37  	setup := testutils.NewSetup(t, t.Name())
    38  	inst := setup.GetTestInstance()
    39  	_, token := setup.GetTestClient(consts.Files)
    40  
    41  	ts := setup.GetTestServerMultipleRoutes(map[string]func(*echo.Group){
    42  		"/files":    files.Routes,
    43  		"/notes":    Routes,
    44  		"/realtime": webRealtime.Routes,
    45  	})
    46  	ts.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler
    47  	t.Cleanup(ts.Close)
    48  
    49  	t.Run("CreateNote", func(t *testing.T) {
    50  		e := testutils.CreateTestClient(t, ts.URL)
    51  
    52  		obj := e.POST("/notes").
    53  			WithHeader("Authorization", "Bearer "+token).
    54  			WithHeader("Content-Type", "application/json").
    55  			WithBytes([]byte(`{
    56          "data": {
    57            "type": "io.cozy.notes.documents",
    58            "attributes": {
    59              "title": "A super note",
    60              "schema": {
    61                "nodes": [
    62                  ["doc", { "content": "block+" }],
    63                  ["paragraph", { "content": "inline*", "group": "block" }],
    64                  ["blockquote", { "content": "block+", "group": "block" }],
    65                  ["horizontal_rule", { "group": "block" }],
    66                  [
    67                    "heading",
    68                    {
    69                      "content": "inline*",
    70                      "group": "block",
    71                      "attrs": { "level": { "default": 1 } }
    72                    }
    73                  ],
    74                  ["code_block", { "content": "text*", "marks": "", "group": "block" }],
    75                  ["text", { "group": "inline" }],
    76                  [
    77                    "image",
    78                    {
    79                      "group": "inline",
    80                      "inline": true,
    81                      "attrs": { "alt": {}, "src": {}, "title": {} }
    82                    }
    83                  ],
    84                  ["hard_break", { "group": "inline", "inline": true }],
    85                  [
    86                    "ordered_list",
    87                    {
    88                      "content": "list_item+",
    89                      "group": "block",
    90                      "attrs": { "order": { "default": 1 } }
    91                    }
    92                  ],
    93                  ["bullet_list", { "content": "list_item+", "group": "block" }],
    94                  ["list_item", { "content": "paragraph block*" }]
    95                ],
    96                "marks": [
    97                  ["link", { "attrs": { "href": {}, "title": {} }, "inclusive": false }],
    98                  ["em", {}],
    99                  ["strong", {}],
   100                  ["code", {}]
   101                ],
   102                "topNode": "doc"
   103              }
   104            }
   105          }
   106        }`)).
   107  			Expect().Status(201).
   108  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   109  			Object()
   110  
   111  		assertInitialNote(t, obj)
   112  
   113  		noteID = obj.Path("$.data.id").String().NotEmpty().Raw()
   114  	})
   115  
   116  	t.Run("GetNote", func(t *testing.T) {
   117  		e := testutils.CreateTestClient(t, ts.URL)
   118  
   119  		obj := e.GET("/notes/"+noteID).
   120  			WithHeader("Authorization", "Bearer "+token).
   121  			Expect().Status(200).
   122  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   123  			Object()
   124  
   125  		assertInitialNote(t, obj)
   126  	})
   127  
   128  	t.Run("OpenNote", func(t *testing.T) {
   129  		e := testutils.CreateTestClient(t, ts.URL)
   130  
   131  		obj := e.GET("/notes/"+noteID+"/open").
   132  			WithHeader("Authorization", "Bearer "+token).
   133  			Expect().Status(200).
   134  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   135  			Object()
   136  
   137  		data := obj.Value("data").Object()
   138  		data.HasValue("type", consts.NotesURL)
   139  		data.HasValue("id", noteID)
   140  
   141  		attrs := data.Value("attributes").Object()
   142  		attrs.HasValue("note_id", noteID)
   143  		attrs.HasValue("subdomain", "nested")
   144  		attrs.HasValue("protocol", "https")
   145  		attrs.HasValue("instance", inst.Domain)
   146  		attrs.Value("public_name").String().NotEmpty()
   147  	})
   148  
   149  	t.Run("ChangeTitleAndSync", func(t *testing.T) {
   150  		e := testutils.CreateTestClient(t, ts.URL)
   151  
   152  		obj := e.PUT("/notes/"+noteID+"/title").
   153  			WithHeader("Authorization", "Bearer "+token).
   154  			WithHeader("Content-Type", "application/vnd.api+json").
   155  			WithBytes([]byte(`{
   156          "data": {
   157            "type": "io.cozy.notes.documents",
   158            "attributes": {
   159              "sessionID": "543781490137",
   160              "title": "A new title"
   161            }
   162          }
   163        }`)).
   164  			Expect().Status(200).
   165  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   166  			Object()
   167  
   168  		data := obj.Value("data").Object()
   169  		data.HasValue("type", "io.cozy.files")
   170  		data.HasValue("id", noteID)
   171  
   172  		attrs := data.Value("attributes").Object()
   173  		meta := attrs.Value("metadata").Object()
   174  
   175  		meta.HasValue("title", "A new title")
   176  		meta.HasValue("version", 0)
   177  		meta.Value("schema").Object().NotEmpty()
   178  		meta.Value("content").Object().NotEmpty()
   179  
   180  		// The change was only made in cache, but we have to force persisting the
   181  		// change to the VFS to check that renaming the file works.
   182  		e.POST("/notes/"+noteID+"/sync").
   183  			WithHeader("Authorization", "Bearer "+token).
   184  			Expect().Status(204)
   185  
   186  		obj = e.GET("/notes/"+noteID).
   187  			WithHeader("Authorization", "Bearer "+token).
   188  			Expect().Status(200).
   189  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   190  			Object()
   191  
   192  		data = obj.Value("data").Object()
   193  		data.HasValue("type", "io.cozy.files")
   194  		data.HasValue("id", noteID)
   195  
   196  		attrs = data.Value("attributes").Object()
   197  		attrs.HasValue("name", "A new title.cozy-note")
   198  
   199  		meta = attrs.Value("metadata").Object()
   200  		meta.HasValue("title", "A new title")
   201  		meta.HasValue("version", 0)
   202  		meta.Value("schema").Object().NotEmpty()
   203  		meta.Value("content").Object().NotEmpty()
   204  	})
   205  
   206  	t.Run("ListNotes", func(t *testing.T) {
   207  		e := testutils.CreateTestClient(t, ts.URL)
   208  
   209  		// Change the title
   210  		e.PUT("/notes/"+noteID+"/title").
   211  			WithHeader("Authorization", "Bearer "+token).
   212  			WithHeader("Content-Type", "application/vnd.api+json").
   213  			WithBytes([]byte(`{
   214          "data": {
   215            "type": "io.cozy.notes.documents",
   216            "attributes": {
   217              "sessionID": "543781490137",
   218              "title": "A title in cache"
   219            }
   220          }
   221        }`)).
   222  			Expect().Status(200)
   223  
   224  		// The title has been changed in cache, but we don't wait that the file has been renamed
   225  		obj := e.GET("/notes").
   226  			WithHeader("Authorization", "Bearer "+token).
   227  			Expect().Status(200).
   228  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   229  			Object()
   230  
   231  		data := obj.Value("data").Array()
   232  		data.Length().IsEqual(1)
   233  
   234  		doc := data.Value(0).Object()
   235  		doc.HasValue("type", "io.cozy.files")
   236  		doc.HasValue("id", noteID)
   237  
   238  		attrs := doc.Value("attributes").Object()
   239  		attrs.HasValue("name", "A new title.cozy-note")
   240  		attrs.Value("path").String().HasSuffix("/A new title.cozy-note")
   241  		attrs.HasValue("mime", "text/vnd.cozy.note+markdown")
   242  
   243  		meta := attrs.Value("metadata").Object()
   244  		meta.HasValue("title", "A title in cache")
   245  		meta.HasValue("version", 0)
   246  		meta.Value("schema").Object().NotEmpty()
   247  		meta.Value("content").Object().NotEmpty()
   248  	})
   249  
   250  	t.Run("PatchNote", func(t *testing.T) {
   251  		body := []byte(`{
   252          "data": [{
   253            "type": "io.cozy.notes.steps",
   254            "attributes": {
   255              "sessionID": "543781490137",
   256              "stepType": "replace",
   257              "from": 1,
   258              "to": 1,
   259              "slice": {
   260                "content": [{ "type": "text", "text": "H" }]
   261              }
   262            }
   263          }, {
   264            "type": "io.cozy.notes.steps",
   265            "attributes": {
   266              "sessionID": "543781490137",
   267              "stepType": "replace",
   268              "from": 2,
   269              "to": 2,
   270              "slice": {
   271                "content": [{ "type": "text", "text": "ello" }]
   272              }
   273            }
   274          }]
   275        }`)
   276  
   277  		t.Run("Success", func(t *testing.T) {
   278  			e := testutils.CreateTestClient(t, ts.URL)
   279  
   280  			obj := e.PATCH("/notes/"+noteID).
   281  				WithHeader("Authorization", "Bearer "+token).
   282  				WithHeader("Content-Type", "application/vnd.api+json").
   283  				WithHeader("If-Match", "0").
   284  				WithBytes(body).
   285  				Expect().Status(200).
   286  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   287  				Object()
   288  
   289  			data := obj.Value("data").Object()
   290  			data.HasValue("type", "io.cozy.files")
   291  			data.HasValue("id", noteID)
   292  
   293  			attrs := data.Value("attributes").Object()
   294  			meta := attrs.Value("metadata").Object()
   295  
   296  			version = int64(meta.Value("version").Number().Gt(0).Raw())
   297  			meta.Value("schema").Object().NotEmpty()
   298  			meta.Value("content").Object().NotEmpty()
   299  		})
   300  
   301  		t.Run("WithInvalidIfMatchHeader", func(t *testing.T) {
   302  			e := testutils.CreateTestClient(t, ts.URL)
   303  
   304  			e.PATCH("/notes/"+noteID).
   305  				WithHeader("Authorization", "Bearer "+token).
   306  				WithHeader("Content-Type", "application/vnd.api+json").
   307  				WithHeader("If-Match", "0").
   308  				WithBytes(body).
   309  				Expect().Status(409)
   310  		})
   311  	})
   312  
   313  	t.Run("GetSteps", func(t *testing.T) {
   314  		var lastVersion int
   315  
   316  		body := []byte(`{
   317        "data": [{
   318          "type": "io.cozy.notes.steps",
   319          "attributes": {
   320            "sessionID": "543781490137",
   321            "stepType": "replace",
   322            "from": 6,
   323            "to": 6,
   324            "slice": {
   325              "content": [{ "type": "text", "text": " " }]
   326            }
   327          }
   328        }, {
   329          "type": "io.cozy.notes.steps",
   330          "attributes": {
   331            "sessionID": "543781490137",
   332            "stepType": "replace",
   333            "from": 7,
   334            "to": 7,
   335            "slice": {
   336              "content": [{ "type": "text", "text": "world" }]
   337            }
   338          }
   339        }]
   340      }`)
   341  
   342  		t.Run("Success", func(t *testing.T) {
   343  			e := testutils.CreateTestClient(t, ts.URL)
   344  
   345  			obj := e.PATCH("/notes/"+noteID).
   346  				WithHeader("Authorization", "Bearer "+token).
   347  				WithHeader("Content-Type", "application/vnd.api+json").
   348  				WithHeader("If-Match", strconv.Itoa(int(version))).
   349  				WithBytes(body).
   350  				Expect().Status(200).
   351  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   352  				Object()
   353  
   354  			lastVersion = int(obj.Path("$.data.attributes.metadata.version").Number().Gt(0).Raw())
   355  		})
   356  
   357  		t.Run("GetStepsFromCurrentVersion", func(t *testing.T) {
   358  			e := testutils.CreateTestClient(t, ts.URL)
   359  
   360  			obj := e.GET("/notes/"+noteID+"/steps").
   361  				WithQuery("Version", int(version)).
   362  				WithHeader("Authorization", "Bearer "+token).
   363  				WithHeader("Content-Type", "application/vnd.api+json").
   364  				WithHeader("If-Match", strconv.Itoa(int(version))).
   365  				WithBytes(body).
   366  				Expect().Status(200).
   367  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   368  				Object()
   369  
   370  			obj.Path("$.meta.count").Number().IsEqual(2)
   371  			obj.Path("$.data").Array().Length().IsEqual(2)
   372  
   373  			first := obj.Path("$.data[0]").Object()
   374  			first.Value("id").String().NotEmpty()
   375  
   376  			attrs := first.Value("attributes").Object()
   377  			attrs.HasValue("sessionID", "543781490137")
   378  			attrs.HasValue("stepType", "replace")
   379  			attrs.HasValue("from", 6)
   380  			attrs.HasValue("to", 6)
   381  			attrs.Value("version").Number()
   382  
   383  			second := obj.Path("$.data[1]").Object()
   384  			second.Value("id").String().NotEmpty()
   385  
   386  			attrs = second.Value("attributes").Object()
   387  			attrs.HasValue("sessionID", "543781490137")
   388  			attrs.HasValue("stepType", "replace")
   389  			attrs.HasValue("from", 7)
   390  			attrs.HasValue("to", 7)
   391  			attrs.HasValue("version", lastVersion)
   392  		})
   393  
   394  		t.Run("GetStepsFromLastVersion", func(t *testing.T) {
   395  			e := testutils.CreateTestClient(t, ts.URL)
   396  
   397  			obj := e.GET("/notes/"+noteID+"/steps").
   398  				WithQuery("Version", lastVersion).
   399  				WithHeader("Authorization", "Bearer "+token).
   400  				WithHeader("Content-Type", "application/vnd.api+json").
   401  				WithHeader("If-Match", strconv.Itoa(int(version))).
   402  				WithBytes(body).
   403  				Expect().Status(200).
   404  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   405  				Object()
   406  
   407  			obj.Path("$.meta.count").Number().IsEqual(0)
   408  			obj.Path("$.data").Array().IsEmpty()
   409  
   410  			version = int64(lastVersion)
   411  		})
   412  	})
   413  
   414  	t.Run("PutSchema", func(t *testing.T) {
   415  		e := testutils.CreateTestClient(t, ts.URL)
   416  
   417  		obj := e.PUT("/notes/"+noteID+"/schema").
   418  			WithHeader("Authorization", "Bearer "+token).
   419  			WithHeader("Content-Type", "application/json").
   420  			WithBytes([]byte(`{
   421          "data": {
   422            "type": "io.cozy.notes.documents",
   423            "attributes": {
   424              "schema": {
   425                "nodes": [
   426                  ["doc", { "content": "block+" }],
   427                  [
   428                    "panel",
   429                    {
   430                      "content": "(paragraph | heading | bullet_list | ordered_list)+",
   431                      "group": "block",
   432                      "attrs": { "panelType": { "default": "info" } }
   433                    }
   434                  ],
   435                  ["paragraph", { "content": "inline*", "group": "block" }],
   436                  ["blockquote", { "content": "block+", "group": "block" }],
   437                  ["horizontal_rule", { "group": "block" }],
   438                  [
   439                    "heading",
   440                    {
   441                      "content": "inline*",
   442                      "group": "block",
   443                      "attrs": { "level": { "default": 1 } }
   444                    }
   445                  ],
   446                  ["code_block", { "content": "text*", "marks": "", "group": "block" }],
   447                  ["text", { "group": "inline" }],
   448                  [
   449                    "image",
   450                    {
   451                      "group": "inline",
   452                      "inline": true,
   453                      "attrs": { "alt": {}, "src": {}, "title": {} }
   454                    }
   455                  ],
   456                  ["hard_break", { "group": "inline", "inline": true }],
   457                  [
   458                    "ordered_list",
   459                    {
   460                      "content": "list_item+",
   461                      "group": "block",
   462                      "attrs": { "order": { "default": 1 } }
   463                    }
   464                  ],
   465                  ["bullet_list", { "content": "list_item+", "group": "block" }],
   466                  ["list_item", { "content": "paragraph block*" }]
   467                ],
   468                "marks": [
   469                  ["link", { "attrs": { "href": {}, "title": {} }, "inclusive": false }],
   470                  ["em", {}],
   471                  ["strong", {}],
   472                  ["code", {}]
   473                ],
   474                "version": 2,
   475                "topNode": "doc"
   476              }
   477            }
   478          }
   479        }`)).
   480  			Expect().Status(200).
   481  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   482  			Object()
   483  
   484  		data := obj.Value("data").Object()
   485  		data.HasValue("type", "io.cozy.files")
   486  		data.HasValue("id", noteID)
   487  
   488  		schema := obj.Path("$.data.attributes.metadata.schema").Object()
   489  		schema.HasValue("version", 2)
   490  		schema.Path("$.nodes[1][0]").IsEqual("panel")
   491  
   492  		// TODO: add an explanation why we need this sleep period
   493  		time.Sleep(1 * time.Second)
   494  
   495  		e.GET("/notes/"+noteID+"/steps").
   496  			WithQuery("Version", version).
   497  			WithHeader("Authorization", "Bearer "+token).
   498  			Expect().Status(412)
   499  
   500  		version = int64(obj.Path("$.data.attributes.metadata.version").Number().Raw())
   501  	})
   502  
   503  	t.Run("PutTelepointer", func(t *testing.T) {
   504  		e := testutils.CreateTestClient(t, ts.URL)
   505  
   506  		wg := sync.WaitGroup{}
   507  		wg.Add(1)
   508  		go func() {
   509  			sub := realtime.GetHub().Subscriber(inst)
   510  			sub.Subscribe(consts.NotesEvents)
   511  
   512  			// Suscribtion ok, unlock the first wait
   513  			wg.Done()
   514  
   515  			e := <-sub.Channel
   516  			assert.Equal(t, "UPDATED", e.Verb)
   517  			assert.Equal(t, noteID, e.Doc.ID())
   518  			doc, ok := e.Doc.(note.Event)
   519  			assert.True(t, ok)
   520  			assert.Equal(t, consts.NotesTelepointers, doc["doctype"])
   521  			assert.Equal(t, "543781490137", doc["sessionID"])
   522  			assert.Equal(t, "textSelection", doc["type"])
   523  			assert.EqualValues(t, 7, doc["anchor"])
   524  			assert.EqualValues(t, 12, doc["head"])
   525  
   526  			// Event received and validated, unlock the second wait.
   527  			wg.Done()
   528  		}()
   529  
   530  		// Wait that the goroutine has subscribed to the realtime
   531  		wg.Wait()
   532  
   533  		wg.Add(1)
   534  		e.PUT("/notes/"+noteID+"/telepointer").
   535  			WithHeader("Authorization", "Bearer "+token).
   536  			WithHeader("Content-Type", "application/json").
   537  			WithBytes([]byte(`{
   538          "data": {
   539            "type": "io.cozy.notes.telepointers",
   540            "attributes": {
   541              "sessionID": "543781490137",
   542              "anchor": 7,
   543              "head": 12,
   544              "type": "textSelection"
   545            }
   546          }
   547        }`)).
   548  			Expect().Status(204)
   549  
   550  		// Wait that the goroutine has received the telepointer update
   551  		wg.Wait()
   552  	})
   553  
   554  	t.Run("NoteMarkdown", func(t *testing.T) {
   555  		// Force the changes to the VFS
   556  		err := note.Update(inst, noteID)
   557  		assert.NoError(t, err)
   558  		doc, err := inst.VFS().FileByID(noteID)
   559  		assert.NoError(t, err)
   560  		file, err := inst.VFS().OpenFile(doc)
   561  		assert.NoError(t, err)
   562  		defer file.Close()
   563  		buf, err := io.ReadAll(file)
   564  		assert.NoError(t, err)
   565  		assert.Equal(t, "Hello world", string(buf))
   566  	})
   567  
   568  	t.Run("NoteRealtime", func(t *testing.T) {
   569  		e := testutils.CreateTestClient(t, ts.URL)
   570  
   571  		ws := e.GET("/realtime/").
   572  			WithWebsocketUpgrade().
   573  			Expect().Status(http.StatusSwitchingProtocols).
   574  			Websocket()
   575  		defer ws.Disconnect()
   576  
   577  		ws.WriteText(fmt.Sprintf(`{"method": "AUTH", "payload": "%s"}`, token))
   578  
   579  		ws.WriteText(`{"method": "SUBSCRIBE", "payload": { "type": "io.cozy.notes.events", "id": "` + noteID + `" }}`)
   580  
   581  		// To check that the realtime has made the subscription, we send a fake
   582  		// message and wait for its response.
   583  		ws.WriteText(`{"method": "PING"}`).
   584  			Expect().TextMessage().
   585  			JSON()
   586  
   587  		pointer := note.Event{
   588  			"sessionID": "543781490137",
   589  			"anchor":    7,
   590  			"head":      12,
   591  			"type":      "textSelection",
   592  		}
   593  		pointer.SetID(noteID)
   594  		err := note.PutTelepointer(inst, pointer)
   595  		assert.NoError(t, err)
   596  
   597  		obj := ws.Expect().TextMessage().
   598  			JSON().Object()
   599  
   600  		obj.HasValue("event", "UPDATED")
   601  		payload := obj.Value("payload").Object()
   602  		payload.HasValue("id", noteID)
   603  		payload.HasValue("type", "io.cozy.notes.events")
   604  
   605  		doc := payload.Value("doc").Object()
   606  		doc.HasValue("doctype", "io.cozy.notes.telepointers")
   607  		doc.HasValue("sessionID", "543781490137")
   608  		doc.HasValue("anchor", 7)
   609  		doc.HasValue("head", 12)
   610  		doc.HasValue("type", "textSelection")
   611  
   612  		file, err := inst.VFS().FileByID(noteID)
   613  		require.NoError(t, err)
   614  		file, err = note.UpdateTitle(inst, file, "A very new title", "543781490137")
   615  		require.NoError(t, err)
   616  
   617  		obj = ws.Expect().TextMessage().
   618  			JSON().Object()
   619  
   620  		obj.HasValue("event", "UPDATED")
   621  		payload = obj.Value("payload").Object()
   622  		payload.HasValue("id", noteID)
   623  		payload.HasValue("type", "io.cozy.notes.events")
   624  
   625  		doc = payload.Value("doc").Object()
   626  		doc.HasValue("doctype", "io.cozy.notes.documents")
   627  		doc.HasValue("title", "A very new title")
   628  		doc.HasValue("sessionID", "543781490137")
   629  
   630  		slice := map[string]interface{}{
   631  			"content": []interface{}{
   632  				map[string]interface{}{"type": "text", "text": "X"},
   633  			},
   634  		}
   635  		steps := []note.Step{
   636  			{"sessionID": "543781490137", "stepType": "replace", "from": 2, "to": 2, "slice": slice},
   637  			{"sessionID": "543781490137", "stepType": "replace", "from": 3, "to": 3, "slice": slice},
   638  		}
   639  		file, err = note.ApplySteps(inst, file, fmt.Sprintf("%d", version), steps)
   640  		require.NoError(t, err)
   641  
   642  		obj = ws.Expect().TextMessage().
   643  			JSON().Object()
   644  
   645  		obj.HasValue("event", "UPDATED")
   646  		payload = obj.Value("payload").Object()
   647  		payload.HasValue("id", noteID)
   648  		payload.HasValue("type", "io.cozy.notes.events")
   649  
   650  		doc4 := payload.Value("doc").Object()
   651  
   652  		obj = ws.Expect().TextMessage().
   653  			JSON().Object()
   654  
   655  		obj.HasValue("event", "UPDATED")
   656  		payload = obj.Value("payload").Object()
   657  		payload.HasValue("id", noteID)
   658  		payload.HasValue("type", "io.cozy.notes.events")
   659  		doc5 := payload.Value("doc").Object()
   660  
   661  		// // In some cases, the steps can be received in the bad order because of the
   662  		// // concurrency between the goroutines in the realtime hub.
   663  		if doc4.Value("version").Number().Raw() > doc5.Value("version").Number().Raw() {
   664  			doc4, doc5 = doc5, doc4
   665  		}
   666  
   667  		doc4.HasValue("doctype", "io.cozy.notes.steps")
   668  		doc4.HasValue("sessionID", "543781490137")
   669  		doc4.HasValue("stepType", "replace")
   670  		doc4.HasValue("from", 2)
   671  		doc4.HasValue("to", 2)
   672  		vers4 := int(doc4.Value("version").Number().Gt(0).Raw())
   673  
   674  		doc5.HasValue("doctype", "io.cozy.notes.steps")
   675  		doc5.HasValue("sessionID", "543781490137")
   676  		doc5.HasValue("stepType", "replace")
   677  		doc5.HasValue("from", 3)
   678  		doc5.HasValue("to", 3)
   679  		vers5 := int(doc5.Value("version").Number().
   680  			NotEqual(0).
   681  			NotEqual(vers4).
   682  			Raw())
   683  
   684  		assert.EqualValues(t, file.Metadata["version"], vers5)
   685  	})
   686  
   687  	t.Run("CreateNote with a content", func(t *testing.T) {
   688  		e := testutils.CreateTestClient(t, ts.URL)
   689  
   690  		obj := e.POST("/notes").
   691  			WithHeader("Authorization", "Bearer "+token).
   692  			WithHeader("Content-Type", "application/json").
   693  			WithBytes([]byte(`{
   694          "data": {
   695            "type": "io.cozy.notes.documents",
   696            "attributes": {
   697              "title": "A note with some content",
   698              "schema": {
   699                "nodes": [
   700                  ["doc", { "content": "block+" }],
   701                  ["paragraph", { "content": "inline*", "group": "block" }],
   702                  ["text", { "group": "inline" }],
   703                  ["bullet_list", { "content": "list_item+", "group": "block" }],
   704                  ["list_item", { "content": "paragraph block*" }]
   705                ],
   706                "marks": [
   707                  ["em", {}],
   708                  ["strong", {}]
   709                ],
   710                "topNode": "doc"
   711              },
   712              "content": {
   713                "content": [
   714                  {
   715                    "content": [{ "text": "Hello world", "type": "text" }],
   716                    "type": "paragraph"
   717                  }
   718                ],
   719                "type": "doc"
   720              }
   721            }
   722          }
   723        }`)).
   724  			Expect().Status(201).
   725  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   726  			Object()
   727  
   728  		data := obj.Value("data").Object()
   729  
   730  		data.HasValue("type", "io.cozy.files")
   731  		otherNoteID = data.Value("id").String().NotEmpty().Raw()
   732  
   733  		attrs := data.Value("attributes").Object()
   734  		attrs.HasValue("type", "file")
   735  		attrs.HasValue("name", "A note with some content.cozy-note")
   736  		attrs.HasValue("mime", "text/vnd.cozy.note+markdown")
   737  
   738  		meta := attrs.Value("metadata").Object()
   739  		meta.HasValue("title", "A note with some content")
   740  		meta.HasValue("version", 0)
   741  		meta.Value("schema").Object().NotEmpty()
   742  
   743  		expected := map[string]interface{}{
   744  			"content": []interface{}{
   745  				map[string]interface{}{
   746  					"content": []interface{}{
   747  						map[string]interface{}{"text": "Hello world", "type": "text"},
   748  					},
   749  					"type": "paragraph",
   750  				},
   751  			},
   752  			"type": "doc",
   753  		}
   754  		meta.Value("content").Object().IsEqual(expected)
   755  	})
   756  
   757  	t.Run("UploadImage", func(t *testing.T) {
   758  		e := testutils.CreateTestClient(t, ts.URL)
   759  
   760  		rawFile, err := os.ReadFile("../../tests/fixtures/wet-cozy_20160910__M4Dz.jpg")
   761  		require.NoError(t, err)
   762  
   763  		for i := 0; i < 3; i++ {
   764  			obj := e.POST("/notes/"+noteID+"/images").
   765  				WithQuery("Name", "wet.jpg").
   766  				WithHeader("Authorization", "Bearer "+token).
   767  				WithHeader("Content-Type", "image/jpeg").
   768  				WithBytes(rawFile).
   769  				Expect().Status(201).
   770  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   771  				Object()
   772  
   773  			data := obj.Value("data").Object()
   774  			data.HasValue("type", consts.NotesImages)
   775  			data.Value("id").String().NotEmpty()
   776  			data.Value("meta").Object().NotEmpty()
   777  
   778  			attrs := data.Value("attributes").Object()
   779  			if i == 0 {
   780  				attrs.HasValue("name", "wet.jpg")
   781  			} else {
   782  				attrs.HasValue("name", fmt.Sprintf("wet (%d).jpg", i+1))
   783  			}
   784  
   785  			attrs.Value("cozyMetadata").Object().NotEmpty()
   786  			attrs.HasValue("mime", "image/jpeg")
   787  			attrs.HasValue("width", 440)
   788  			attrs.HasValue("height", 294)
   789  
   790  			data.Path("$.links.self").String().NotEmpty()
   791  		}
   792  	})
   793  
   794  	t.Run("CopyImage", func(t *testing.T) {
   795  		e := testutils.CreateTestClient(t, ts.URL)
   796  
   797  		rawFile, err := os.ReadFile("../../tests/fixtures/wet-cozy_20160910__M4Dz.jpg")
   798  		require.NoError(t, err)
   799  
   800  		obj := e.POST("/notes/"+noteID+"/images").
   801  			WithQuery("Name", "tobecopied.jpg").
   802  			WithHeader("Authorization", "Bearer "+token).
   803  			WithHeader("Content-Type", "image/jpeg").
   804  			WithBytes(rawFile).
   805  			Expect().Status(201).
   806  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   807  			Object()
   808  
   809  		data := obj.Value("data").Object()
   810  		imgID := data.Value("id").String().NotEmpty().Raw()
   811  
   812  		obj = e.POST("/notes/"+imgID+"/copy").
   813  			WithQuery("To", otherNoteID).
   814  			WithHeader("Authorization", "Bearer "+token).
   815  			Expect().Status(201).
   816  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   817  			Object()
   818  
   819  		data = obj.Value("data").Object()
   820  		data.HasValue("type", consts.NotesImages)
   821  		data.Value("id").String().NotEmpty().NotEqual(imgID)
   822  		data.Value("meta").Object().NotEmpty()
   823  
   824  		attrs := data.Value("attributes").Object()
   825  		attrs.HasValue("name", "tobecopied.jpg")
   826  
   827  		attrs.Value("cozyMetadata").Object().NotEmpty()
   828  		attrs.HasValue("mime", "image/jpeg")
   829  		attrs.HasValue("width", 440)
   830  		attrs.HasValue("height", 294)
   831  
   832  		data.Path("$.links.self").String().NotEmpty()
   833  	})
   834  
   835  	t.Run("GetImage", func(t *testing.T) {
   836  		e := testutils.CreateTestClient(t, ts.URL)
   837  
   838  		rawFile, err := os.ReadFile("../../tests/fixtures/wet-cozy_20160910__M4Dz.jpg")
   839  		require.NoError(t, err)
   840  
   841  		obj := e.POST("/notes/"+noteID+"/images").
   842  			WithQuery("Name", "wet.jpg").
   843  			WithHeader("Authorization", "Bearer "+token).
   844  			WithHeader("Content-Type", "image/jpeg").
   845  			WithBytes(rawFile).
   846  			Expect().Status(201).
   847  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   848  			Object()
   849  
   850  		data := obj.Value("data").Object()
   851  		data.HasValue("type", consts.NotesImages)
   852  		data.Value("id").String().NotEmpty()
   853  		data.Value("meta").Object().NotEmpty()
   854  
   855  		link := data.Path("$.links.self").String().NotEmpty().Raw()
   856  
   857  		e.GET(link).
   858  			Expect().Status(200).
   859  			Body().IsEqual(string(rawFile))
   860  
   861  		obj = e.GET("/files/"+noteID).
   862  			WithHeader("Authorization", "Bearer "+token).
   863  			Expect().Status(200).
   864  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   865  			Object()
   866  
   867  		image := obj.Value("included").Array().
   868  			Find(func(_ int, value *httpexpect.Value) bool {
   869  				value.Object().NotHasValue("type", consts.FilesVersions)
   870  				return true
   871  			}).
   872  			Object()
   873  
   874  		image.HasValue("type", consts.NotesImages)
   875  		image.Value("id").String().NotEmpty()
   876  		image.Value("meta").Object().NotEmpty()
   877  
   878  		attrs := image.Value("attributes").Object()
   879  		attrs.Value("name").String().NotEmpty()
   880  		attrs.Value("cozyMetadata").Object().NotEmpty()
   881  		attrs.HasValue("mime", "image/jpeg")
   882  
   883  		data.Path("$.links.self").String().NotEmpty()
   884  	})
   885  
   886  	t.Run("ImportNotes", func(t *testing.T) {
   887  		e := testutils.CreateTestClient(t, ts.URL)
   888  
   889  		obj := e.POST("/files/io.cozy.files.root-dir").
   890  			WithQuery("Type", "file").
   891  			WithQuery("Name", "An imported note.cozy-note").
   892  			WithHeader("Authorization", "Bearer "+token).
   893  			WithHeader("Content-Type", "text/plain").
   894  			WithBytes([]byte(`
   895          # Title
   896  
   897          Text with **bold** and [underlined]{.underlined}.
   898        `)).
   899  			Expect().Status(201).
   900  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   901  			Object()
   902  
   903  		data := obj.Value("data").Object()
   904  		data.HasValue("type", "io.cozy.files")
   905  		fileID := data.Value("id").String().NotEmpty().Raw()
   906  
   907  		attrs := data.Value("attributes").Object()
   908  		attrs.HasValue("type", "file")
   909  		attrs.HasValue("name", "An imported note.cozy-note")
   910  		attrs.HasValue("mime", "text/vnd.cozy.note+markdown")
   911  
   912  		meta := attrs.Value("metadata").Object()
   913  		meta.HasValue("title", "An imported note")
   914  		meta.Value("schema").Object().NotEmpty()
   915  		meta.Value("content").Object().NotEmpty()
   916  
   917  		obj = e.GET("/notes/"+fileID+"/open").
   918  			WithHeader("Authorization", "Bearer "+token).
   919  			Expect().Status(200).
   920  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   921  			Object()
   922  
   923  		data = obj.Value("data").Object()
   924  		data.HasValue("id", fileID)
   925  		data.Path("$.attributes.instance").IsEqual(inst.Domain)
   926  	})
   927  
   928  	t.Run("CopyNoteWithAnImage", func(t *testing.T) {
   929  		e := testutils.CreateTestClient(t, ts.URL)
   930  
   931  		toImport, err := os.ReadFile("../../tests/fixtures/note-with-an-image.cozy-note")
   932  		require.NoError(t, err)
   933  
   934  		obj := e.POST("/files/io.cozy.files.root-dir").
   935  			WithQuery("Type", "file").
   936  			WithQuery("Name", "Note with an image.cozy-note").
   937  			WithHeader("Authorization", "Bearer "+token).
   938  			WithHeader("Content-Type", "text/plain").
   939  			WithBytes(toImport).
   940  			Expect().Status(201).
   941  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   942  			Object()
   943  
   944  		data := obj.Value("data").Object()
   945  		data.HasValue("type", "io.cozy.files")
   946  		srcID := data.Value("id").String().NotEmpty().Raw()
   947  
   948  		obj = e.GET("/files/"+srcID).
   949  			WithHeader("Authorization", "Bearer "+token).
   950  			Expect().Status(200).
   951  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   952  			Object()
   953  
   954  		image := obj.Value("included").Array().
   955  			Find(func(_ int, value *httpexpect.Value) bool {
   956  				value.Object().NotHasValue("type", consts.FilesVersions)
   957  				return true
   958  			}).
   959  			Object()
   960  
   961  		image.HasValue("type", consts.NotesImages)
   962  		image.Value("id").String().NotEmpty()
   963  		image.Value("meta").Object().NotEmpty()
   964  
   965  		attrs := image.Value("attributes").Object()
   966  		attrs.Value("name").String().NotEmpty()
   967  		attrs.Value("cozyMetadata").Object().NotEmpty()
   968  		attrs.HasValue("mime", "image/png")
   969  
   970  		link := data.Path("$.links.self").String().NotEmpty().Raw()
   971  
   972  		e.GET(link).
   973  			WithHeader("Authorization", "Bearer "+token).
   974  			Expect().Status(200)
   975  
   976  		obj = e.POST("/files/"+srcID+"/copy").
   977  			WithHeader("Authorization", "Bearer "+token).
   978  			Expect().Status(201).
   979  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   980  			Object()
   981  
   982  		data = obj.Value("data").Object()
   983  		data.HasValue("type", "io.cozy.files")
   984  		dstID := data.Value("id").String().NotEmpty().Raw()
   985  
   986  		obj = e.GET("/files/"+dstID).
   987  			WithHeader("Authorization", "Bearer "+token).
   988  			Expect().Status(200).
   989  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   990  			Object()
   991  
   992  		image = obj.Value("included").Array().
   993  			Find(func(_ int, value *httpexpect.Value) bool {
   994  				value.Object().NotHasValue("type", consts.FilesVersions)
   995  				return true
   996  			}).
   997  			Object()
   998  
   999  		image.HasValue("type", consts.NotesImages)
  1000  		image.Value("id").String().NotEmpty()
  1001  		image.Value("meta").Object().NotEmpty()
  1002  
  1003  		attrs = image.Value("attributes").Object()
  1004  		attrs.Value("name").String().NotEmpty()
  1005  		attrs.Value("cozyMetadata").Object().NotEmpty()
  1006  		attrs.HasValue("mime", "image/png")
  1007  
  1008  		link = data.Path("$.links.self").String().NotEmpty().Raw()
  1009  
  1010  		e.GET(link).
  1011  			WithHeader("Authorization", "Bearer "+token).
  1012  			Expect().Status(200)
  1013  	})
  1014  }
  1015  
  1016  func assertInitialNote(t *testing.T, obj *httpexpect.Object) {
  1017  	data := obj.Value("data").Object()
  1018  
  1019  	data.HasValue("type", "io.cozy.files")
  1020  	data.Value("id").String().NotEmpty()
  1021  
  1022  	attrs := data.Value("attributes").Object()
  1023  	attrs.HasValue("type", "file")
  1024  	attrs.HasValue("name", "A super note.cozy-note")
  1025  	attrs.HasValue("mime", "text/vnd.cozy.note+markdown")
  1026  
  1027  	fcm := attrs.Value("cozyMetadata").Object()
  1028  	fcm.Value("createdAt").String().AsDateTime(time.RFC3339)
  1029  	fcm.Value("createdOn").String().NotEmpty()
  1030  
  1031  	meta := attrs.Value("metadata").Object()
  1032  	meta.HasValue("title", "A super note")
  1033  	meta.HasValue("version", 0)
  1034  	meta.Value("schema").Object().NotEmpty()
  1035  	meta.Value("content").Object().NotEmpty()
  1036  }