test(release): cover 0.30.0 changelog promises
This commit is contained in:
parent
8054cd536d
commit
c536434b81
14 changed files with 646 additions and 12 deletions
52
internal/storage/s3/s3_test.go
Normal file
52
internal/storage/s3/s3_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
func TestNewClientInsecureSkipTLSVerify(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, "/memos/note.txt", r.URL.Path)
|
||||
_, err := w.Write([]byte("stored memo"))
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
newConfig := func(skipVerify bool) *storepb.StorageS3Config {
|
||||
return &storepb.StorageS3Config{
|
||||
AccessKeyId: "access-key",
|
||||
AccessKeySecret: "access-secret",
|
||||
Endpoint: server.URL,
|
||||
Region: "us-east-1",
|
||||
Bucket: "memos",
|
||||
UsePathStyle: true,
|
||||
InsecureSkipTlsVerify: skipVerify,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("rejects self-signed certificate by default", func(t *testing.T) {
|
||||
client, err := NewClient(context.Background(), newConfig(false))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.GetObject(context.Background(), "note.txt")
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "certificate")
|
||||
})
|
||||
|
||||
t.Run("accepts self-signed certificate when enabled", func(t *testing.T) {
|
||||
client, err := NewClient(context.Background(), newConfig(true))
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := client.GetObject(context.Background(), "note.txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("stored memo"), content)
|
||||
})
|
||||
}
|
||||
46
scripts/entrypoint_test.go
Normal file
46
scripts/entrypoint_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package scripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEntrypointDoesNotLoopWhenTargetUIDIsRoot(t *testing.T) {
|
||||
mockBin := t.TempDir()
|
||||
writeExecutable(t, filepath.Join(mockBin, "id"), "#!/bin/sh\nprintf '0\\n'\n")
|
||||
writeExecutable(t, filepath.Join(mockBin, "chown"), "#!/bin/sh\nexit 0\n")
|
||||
writeExecutable(t, filepath.Join(mockBin, "su-exec"), "#!/bin/sh\nshift\nexec \"$@\"\n")
|
||||
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
entrypoint := filepath.Join(filepath.Dir(currentFile), "entrypoint.sh")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", entrypoint, "sh", "-c", "printf 'started-once'")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+mockBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"MEMOS_UID=0",
|
||||
"MEMOS_GID=0",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(output))
|
||||
require.NotEqual(t, context.DeadlineExceeded, ctx.Err(), "entrypoint recursed instead of launching the command")
|
||||
require.Contains(t, string(output), "memos: starting as UID:GID 0:0")
|
||||
require.Equal(t, 1, strings.Count(string(output), "memos: starting as UID:GID 0:0"))
|
||||
require.Contains(t, string(output), "started-once")
|
||||
}
|
||||
|
||||
func writeExecutable(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0755))
|
||||
}
|
||||
|
|
@ -711,11 +711,12 @@ func TestUpdateInstanceSetting(t *testing.T) {
|
|||
Value: &v1pb.InstanceSetting_StorageSetting_{
|
||||
StorageSetting: &v1pb.InstanceSetting_StorageSetting{
|
||||
S3Config: &v1pb.InstanceSetting_StorageSetting_S3Config{
|
||||
AccessKeyId: "AKID",
|
||||
AccessKeySecret: "super-secret",
|
||||
Endpoint: "s3.example.com",
|
||||
Region: "us-east-1",
|
||||
Bucket: "memos",
|
||||
AccessKeyId: "AKID",
|
||||
AccessKeySecret: "super-secret",
|
||||
Endpoint: "s3.example.com",
|
||||
Region: "us-east-1",
|
||||
Bucket: "memos",
|
||||
InsecureSkipTlsVerify: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -730,6 +731,8 @@ func TestUpdateInstanceSetting(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Empty(t, resp.GetStorageSetting().GetS3Config().GetAccessKeySecret(),
|
||||
"AccessKeySecret must never be returned in responses")
|
||||
require.True(t, resp.GetStorageSetting().GetS3Config().GetInsecureSkipTlsVerify(),
|
||||
"insecure_skip_tls_verify must round-trip through the API")
|
||||
|
||||
// Update with empty secret; original must be preserved in the store.
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{
|
||||
|
|
@ -738,11 +741,12 @@ func TestUpdateInstanceSetting(t *testing.T) {
|
|||
Value: &v1pb.InstanceSetting_StorageSetting_{
|
||||
StorageSetting: &v1pb.InstanceSetting_StorageSetting{
|
||||
S3Config: &v1pb.InstanceSetting_StorageSetting_S3Config{
|
||||
AccessKeyId: "AKID",
|
||||
AccessKeySecret: "", // omitted / not changed
|
||||
Endpoint: "s3-v2.example.com",
|
||||
Region: "us-east-1",
|
||||
Bucket: "memos",
|
||||
AccessKeyId: "AKID",
|
||||
AccessKeySecret: "", // omitted / not changed
|
||||
Endpoint: "s3-v2.example.com",
|
||||
Region: "us-east-1",
|
||||
Bucket: "memos",
|
||||
InsecureSkipTlsVerify: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -755,6 +759,7 @@ func TestUpdateInstanceSetting(t *testing.T) {
|
|||
require.Equal(t, "super-secret", stored.GetS3Config().GetAccessKeySecret(),
|
||||
"existing AccessKeySecret must be preserved when an empty value is sent")
|
||||
require.Equal(t, "s3-v2.example.com", stored.GetS3Config().GetEndpoint())
|
||||
require.True(t, stored.GetS3Config().GetInsecureSkipTlsVerify())
|
||||
})
|
||||
|
||||
t.Run("UpdateInstanceSetting - AI provider keys are write-only and preserved on empty", func(t *testing.T) {
|
||||
|
|
|
|||
51
server/router/api/v1/test/personal_access_token_test.go
Normal file
51
server/router/api/v1/test/personal_access_token_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
||||
func TestCreatePersonalAccessTokenExpiration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
user, err := ts.CreateRegularUser(ctx, "pat-expiration")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
parent := "users/" + user.Username
|
||||
|
||||
t.Run("zero days never expires", func(t *testing.T) {
|
||||
response, err := ts.Service.CreatePersonalAccessToken(userCtx, &v1pb.CreatePersonalAccessTokenRequest{
|
||||
Parent: parent,
|
||||
Description: "non-expiring token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(response.Token, "memos_pat_"))
|
||||
require.Nil(t, response.PersonalAccessToken.ExpiresAt)
|
||||
|
||||
listed, err := ts.Service.ListPersonalAccessTokens(userCtx, &v1pb.ListPersonalAccessTokensRequest{Parent: parent})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listed.PersonalAccessTokens, 1)
|
||||
require.Nil(t, listed.PersonalAccessTokens[0].ExpiresAt)
|
||||
})
|
||||
|
||||
t.Run("positive days sets expiration", func(t *testing.T) {
|
||||
before := time.Now().Add(29 * 24 * time.Hour)
|
||||
response, err := ts.Service.CreatePersonalAccessToken(userCtx, &v1pb.CreatePersonalAccessTokenRequest{
|
||||
Parent: parent,
|
||||
Description: "expiring token",
|
||||
ExpiresInDays: 30,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response.PersonalAccessToken.ExpiresAt)
|
||||
require.True(t, response.PersonalAccessToken.ExpiresAt.AsTime().After(before))
|
||||
require.True(t, response.PersonalAccessToken.ExpiresAt.AsTime().Before(time.Now().Add(31*24*time.Hour)))
|
||||
})
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package store_test
|
|||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -62,4 +63,42 @@ func TestDemoSeedUsesDeploymentAuthenticationPolicy(t *testing.T) {
|
|||
require.NotNil(t, aliceUser)
|
||||
require.Error(t, bcrypt.CompareHashAndPassword([]byte(aliceUser.PasswordHash), []byte("demo")))
|
||||
require.NotEqual(t, adminUser.PasswordHash, aliceUser.PasswordHash)
|
||||
|
||||
memos, err := stores.ListMemos(ctx, &store.FindMemo{})
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(memos), 20)
|
||||
|
||||
var hasPublic, hasProtected, hasLocation, hasNestedTag, hasMention bool
|
||||
for _, memo := range memos {
|
||||
hasPublic = hasPublic || memo.Visibility == store.Public
|
||||
hasProtected = hasProtected || memo.Visibility == store.Protected
|
||||
hasLocation = hasLocation || memo.Payload.GetLocation() != nil
|
||||
for _, tag := range memo.Payload.GetTags() {
|
||||
hasNestedTag = hasNestedTag || strings.Contains(tag, "/")
|
||||
}
|
||||
hasMention = hasMention || strings.Contains(memo.Content, "@steven")
|
||||
}
|
||||
require.True(t, hasPublic, "demo should include public memos")
|
||||
require.True(t, hasProtected, "demo should include protected memos")
|
||||
require.True(t, hasLocation, "demo should include location metadata")
|
||||
require.True(t, hasNestedTag, "demo should include nested tags")
|
||||
require.True(t, hasMention, "demo should include a mention")
|
||||
|
||||
relations, err := stores.ListMemoRelations(ctx, &store.FindMemoRelation{})
|
||||
require.NoError(t, err)
|
||||
var hasComment, hasReference bool
|
||||
for _, relation := range relations {
|
||||
hasComment = hasComment || relation.Type == store.MemoRelationComment
|
||||
hasReference = hasReference || relation.Type == store.MemoRelationReference
|
||||
}
|
||||
require.True(t, hasComment, "demo should include comments")
|
||||
require.True(t, hasReference, "demo should include memo references")
|
||||
|
||||
reactions, err := stores.ListReactions(ctx, &store.FindReaction{})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, reactions, "demo should include reactions")
|
||||
|
||||
attachments, err := stores.ListAttachments(ctx, &store.FindAttachment{HasRelatedMemo: true})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, attachments, "demo should include a memo attachment")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ describe("<About>", () => {
|
|||
expect(screen.getByRole("link", { name: /about\.official-website/ })).toHaveAttribute("href", "https://usememos.com/");
|
||||
expect(screen.getByRole("link", { name: /about\.documents/ })).toHaveAttribute("href", "https://usememos.com/docs");
|
||||
expect(screen.getByRole("link", { name: /API Docs/ })).toHaveAttribute("href", "https://usememos.com/docs/api");
|
||||
expect(screen.getByRole("link", { name: /Web Clipper/ })).toHaveAttribute("href", "https://github.com/usememos/web-clipper");
|
||||
expect(screen.getByRole("link", { name: /about\.github-repository/ })).toHaveAttribute("href", "https://github.com/usememos/memos");
|
||||
});
|
||||
|
||||
|
|
|
|||
49
web/tests/create-access-token-dialog.test.tsx
Normal file
49
web/tests/create-access-token-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CreateAccessTokenDialog from "@/components/CreateAccessTokenDialog";
|
||||
|
||||
const createPersonalAccessToken = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/connect", () => ({
|
||||
userServiceClient: {
|
||||
createPersonalAccessToken,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
default: () => ({ name: "users/alice" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("CreateAccessTokenDialog", () => {
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
Element.prototype.setPointerCapture = vi.fn();
|
||||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
createPersonalAccessToken.mockReset();
|
||||
createPersonalAccessToken.mockResolvedValue({ token: "" });
|
||||
});
|
||||
|
||||
it("creates a non-expiring token by default", async () => {
|
||||
render(<CreateAccessTokenDialog open onOpenChange={vi.fn()} onSuccess={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "setting.access-token.create-dialog.duration-never" })).toBeChecked();
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "CLI token" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createPersonalAccessToken).toHaveBeenCalledWith({
|
||||
parent: "users/alice",
|
||||
description: "CLI token",
|
||||
expiresInDays: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
88
web/tests/create-webhook-dialog.test.tsx
Normal file
88
web/tests/create-webhook-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CreateWebhookDialog from "@/components/CreateWebhookDialog";
|
||||
|
||||
const clients = vi.hoisted(() => ({
|
||||
createUserWebhook: vi.fn(),
|
||||
getUserWebhookSigningSecret: vi.fn(),
|
||||
listUserWebhooks: vi.fn(),
|
||||
updateUserWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/connect", () => ({
|
||||
userServiceClient: clients,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
default: () => ({ name: "users/alice" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("CreateWebhookDialog", () => {
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
Element.prototype.setPointerCapture = vi.fn();
|
||||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
for (const client of Object.values(clients)) {
|
||||
client.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
it("reveals the server-generated secret after creating a webhook", async () => {
|
||||
clients.createUserWebhook.mockResolvedValue({ name: "users/alice/webhooks/deploy", signingSecretSet: true });
|
||||
clients.getUserWebhookSigningSecret.mockResolvedValue({ signingSecret: "whsec_created-secret" });
|
||||
const onOpenChange = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
render(<CreateWebhookDialog open onOpenChange={onOpenChange} onSuccess={onSuccess} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setting\.webhook\.create-dialog\.title/), { target: { value: "Deploy" } });
|
||||
fireEvent.change(screen.getByLabelText(/setting\.webhook\.create-dialog\.payload-url/), {
|
||||
target: { value: "https://example.com/webhook" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(clients.createUserWebhook).toHaveBeenCalledWith({
|
||||
parent: "users/alice",
|
||||
webhook: { displayName: "Deploy", url: "https://example.com/webhook" },
|
||||
}),
|
||||
);
|
||||
expect(clients.getUserWebhookSigningSecret).toHaveBeenCalledWith({ name: "users/alice/webhooks/deploy" });
|
||||
expect(await screen.findByDisplayValue("whsec_created-secret")).toBeInTheDocument();
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows configured status and reveals an existing secret while editing", async () => {
|
||||
clients.listUserWebhooks.mockResolvedValue({
|
||||
webhooks: [
|
||||
{
|
||||
name: "users/alice/webhooks/deploy",
|
||||
displayName: "Deploy",
|
||||
url: "https://example.com/webhook",
|
||||
signingSecretSet: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
clients.getUserWebhookSigningSecret.mockResolvedValue({ signingSecret: "whsec_existing-secret" });
|
||||
|
||||
render(<CreateWebhookDialog open webhookName="users/alice/webhooks/deploy" onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("setting.webhook.create-dialog.signing-secret-configured")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /setting\.webhook\.create-dialog\.reveal-secret/ }));
|
||||
|
||||
expect(await screen.findByDisplayValue("whsec_existing-secret")).toBeInTheDocument();
|
||||
expect(clients.getUserWebhookSigningSecret).toHaveBeenCalledWith({ name: "users/alice/webhooks/deploy" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { GFM } from "@lezer/markdown";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFormattingController } from "@/components/MemoEditor/Editor/formatting";
|
||||
|
||||
function setup(doc: string, from: number, to: number) {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [markdown()], selection: EditorSelection.range(from, to) }),
|
||||
state: EditorState.create({ doc, extensions: [markdown({ extensions: [GFM] })], selection: EditorSelection.range(from, to) }),
|
||||
});
|
||||
return { view, f: createFormattingController(view, new Set()) };
|
||||
}
|
||||
|
|
@ -53,4 +54,28 @@ describe("formatting controller", () => {
|
|||
f.run("code");
|
||||
expect(view.state.doc.toString()).toBe("code here");
|
||||
});
|
||||
|
||||
it("toggles strikethrough and reports it active", () => {
|
||||
const { view, f } = setup("obsolete text", 0, 8);
|
||||
|
||||
f.run("strikethrough");
|
||||
|
||||
expect(view.state.doc.toString()).toBe("~~obsolete~~ text");
|
||||
expect(f.getActiveFormats().strikethrough).toBe(true);
|
||||
|
||||
f.run("strikethrough");
|
||||
expect(view.state.doc.toString()).toBe("obsolete text");
|
||||
});
|
||||
|
||||
it("toggles a fenced code block and reports it active", () => {
|
||||
const { view, f } = setup("first\nsecond", 0, 12);
|
||||
|
||||
f.run("codeBlock");
|
||||
|
||||
expect(view.state.doc.toString()).toBe("```\nfirst\nsecond\n```");
|
||||
expect(f.getActiveFormats().codeBlock).toBe(true);
|
||||
|
||||
f.run("codeBlock");
|
||||
expect(view.state.doc.toString()).toBe("first\nsecond");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
23
web/tests/location-display-view.test.tsx
Normal file
23
web/tests/location-display-view.test.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import LocationDisplayView from "@/components/MemoMetadata/Location/LocationDisplayView";
|
||||
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
vi.mock("@/components/map/LazyLocationPicker", () => ({
|
||||
LazyLocationPicker: () => <div data-testid="location-picker" />,
|
||||
}));
|
||||
|
||||
describe("LocationDisplayView", () => {
|
||||
it("truncates the visible address while preserving the full value as a title", () => {
|
||||
const placeholder = "A very long street address that should not force the memo metadata row wider than its container";
|
||||
const location = { latitude: 1.3521, longitude: 103.8198, placeholder } as Location;
|
||||
|
||||
render(<LocationDisplayView location={location} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: new RegExp(placeholder) });
|
||||
const visibleAddress = screen.getByText(placeholder);
|
||||
expect(trigger).toHaveAttribute("title", placeholder);
|
||||
expect(trigger).toHaveClass("max-w-full", "min-w-0");
|
||||
expect(visibleAddress).toHaveClass("min-w-0", "truncate");
|
||||
});
|
||||
});
|
||||
58
web/tests/memo-comments-pagination.test.tsx
Normal file
58
web/tests/memo-comments-pagination.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listMemoComments = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/connect", () => ({
|
||||
memoServiceClient: {
|
||||
listMemoComments,
|
||||
},
|
||||
}));
|
||||
|
||||
import { useInfiniteMemoComments } from "@/hooks/useMemoQueries";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("useInfiniteMemoComments", () => {
|
||||
beforeEach(() => {
|
||||
listMemoComments.mockReset();
|
||||
});
|
||||
|
||||
it("follows nextPageToken and flattens every fetched comment page", async () => {
|
||||
listMemoComments
|
||||
.mockResolvedValueOnce({
|
||||
memos: [{ name: "memos/comment-1" }, { name: "memos/comment-2" }],
|
||||
nextPageToken: "page-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
memos: [{ name: "memos/comment-3" }],
|
||||
nextPageToken: "",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useInfiniteMemoComments("memos/parent", { pageSize: 2 }), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.data?.map((memo) => memo.name)).toEqual(["memos/comment-1", "memos/comment-2"]));
|
||||
expect(listMemoComments).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "" }),
|
||||
);
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.data?.map((memo) => memo.name)).toEqual(["memos/comment-1", "memos/comment-2", "memos/comment-3"]),
|
||||
);
|
||||
expect(listMemoComments).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "page-2" }),
|
||||
);
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
});
|
||||
});
|
||||
72
web/tests/memo-content-footnotes.test.tsx
Normal file
72
web/tests/memo-content-footnotes.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { MemoMarkdownRendererCore } from "@/components/MemoContent/MemoMarkdownRenderer";
|
||||
|
||||
const FOOTNOTE_MARKDOWN = "A statement with a note.[^1]\n\n[^1]: The footnote body.";
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location">{`${location.pathname}${location.hash}`}</output>;
|
||||
};
|
||||
|
||||
const renderFootnote = (compact = false) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<div data-memo-content>
|
||||
<MemoMarkdownRendererCore
|
||||
content={FOOTNOTE_MARKDOWN}
|
||||
resolvedMentionUsernames={new Set()}
|
||||
memoName="memos/abc123"
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
<LocationProbe />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("memo footnotes", () => {
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
if (!globalThis.CSS) {
|
||||
Object.defineProperty(globalThis, "CSS", { configurable: true, value: {} });
|
||||
}
|
||||
if (!globalThis.CSS.escape) {
|
||||
globalThis.CSS.escape = (value: string) => value;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps GFM footnote references aligned with their sanitized target ids", () => {
|
||||
const { container } = renderFootnote();
|
||||
|
||||
const reference = container.querySelector<HTMLAnchorElement>("a[data-footnote-ref]");
|
||||
const target = container.querySelector<HTMLElement>("#user-content-fn-1");
|
||||
|
||||
expect(reference).not.toBeNull();
|
||||
expect(reference).toHaveAttribute("href", "/memos/abc123#user-content-fn-1");
|
||||
expect(target).not.toBeNull();
|
||||
expect(container.querySelector("#user-content-user-content-fn-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("scrolls to a footnote inside the same fully rendered memo", () => {
|
||||
const { container } = renderFootnote();
|
||||
const reference = container.querySelector<HTMLAnchorElement>("a[data-footnote-ref]");
|
||||
const target = container.querySelector<HTMLElement>("#user-content-fn-1");
|
||||
const scrollIntoView = vi.fn();
|
||||
target!.scrollIntoView = scrollIntoView;
|
||||
|
||||
fireEvent.click(reference!);
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/");
|
||||
});
|
||||
|
||||
it("navigates compact cards to the memo detail footnote", () => {
|
||||
const { container } = renderFootnote(true);
|
||||
const reference = container.querySelector<HTMLAnchorElement>("a[data-footnote-ref]");
|
||||
|
||||
fireEvent.click(reference!);
|
||||
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/memos/abc123#user-content-fn-1");
|
||||
});
|
||||
});
|
||||
107
web/tests/memo-detail-sidebar.test.tsx
Normal file
107
web/tests/memo-detail-sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import MemoDetailSidebar from "@/components/MemoDetailSidebar/MemoDetailSidebar";
|
||||
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { LocationSchema, MemoSchema, Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const updateMemo = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("copy-to-clipboard", () => ({ default: vi.fn() }));
|
||||
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn() } }));
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children, render }: { children: React.ReactNode; render: React.ReactElement }) => (
|
||||
<>
|
||||
{render}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/MemoEditor/Toolbar/VisibilitySelector", () => ({
|
||||
default: ({ onChange }: { onChange: (visibility: Visibility) => void }) => (
|
||||
<button type="button" onClick={() => onChange(Visibility.PRIVATE)}>
|
||||
visibility-selector
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/UserAvatar", () => ({ default: () => <div data-testid="avatar" /> }));
|
||||
vi.mock("@/components/MemoDetailSidebar/MemoOutline", () => ({
|
||||
default: ({ headings }: { headings: unknown[] }) => <div data-testid="outline">{headings.length}</div>,
|
||||
}));
|
||||
vi.mock("@/components/MemoDetailSidebar/MemoSharePanel", () => ({ default: () => <div data-testid="share-panel" /> }));
|
||||
vi.mock("@/components/MemoMetadata/Relation/useResolvedRelationMemos", () => ({ useResolvedRelationMemos: () => ({}) }));
|
||||
vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ profile: { instanceUrl: "https://memos.example" } }) }));
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/alice" }) }));
|
||||
vi.mock("@/hooks/useMemoQueries", () => ({ useUpdateMemo: () => ({ mutateAsync: updateMemo }) }));
|
||||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
useUser: () => ({ data: { username: "alice", displayName: "Alice", avatarUrl: "" } }),
|
||||
}));
|
||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
vi.mock("@/i18n", () => ({ default: { language: "en-US" } }));
|
||||
|
||||
describe("MemoDetailSidebar", () => {
|
||||
beforeEach(() => {
|
||||
updateMemo.mockReset();
|
||||
updateMemo.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders the property rail and wires editable quick actions", async () => {
|
||||
const memo = create(MemoSchema, {
|
||||
name: "memos/detail",
|
||||
creator: "users/alice",
|
||||
state: State.NORMAL,
|
||||
visibility: Visibility.PUBLIC,
|
||||
content: "# Overview\n\n## Details",
|
||||
tags: ["release/0.30"],
|
||||
location: create(LocationSchema, { placeholder: "Singapore" }),
|
||||
attachments: [
|
||||
create(AttachmentSchema, {
|
||||
name: "attachments/spec",
|
||||
filename: "release-notes.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2048n,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const onShareImageOpen = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoDetailSidebar memo={memo} onShareImageOpen={onShareImageOpen} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.visibility")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created-at")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.author")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.location")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.tags")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.attachments")).toBeInTheDocument();
|
||||
expect(screen.getByText("memo.outline")).toBeInTheDocument();
|
||||
expect(screen.getByText("release/0.30")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /release-notes\.pdf/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("outline")).toHaveTextContent("2");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.pin" }));
|
||||
await waitFor(() =>
|
||||
expect(updateMemo).toHaveBeenCalledWith({ update: { name: "memos/detail", pinned: true }, updateMask: ["pinned"] }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "visibility-selector" }));
|
||||
await waitFor(() =>
|
||||
expect(updateMemo).toHaveBeenCalledWith({
|
||||
update: { name: "memos/detail", visibility: Visibility.PRIVATE },
|
||||
updateMask: ["visibility"],
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "memo.share.open-image" }));
|
||||
expect(onShareImageOpen).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "memo.share.open-panel" }));
|
||||
expect(screen.getByTestId("share-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: fa
|
|||
const feed = vi.hoisted(() => ({
|
||||
memos: [] as unknown[],
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(async () => undefined),
|
||||
}));
|
||||
const readiness = vi.hoisted(() => ({ auth: true, instance: true }));
|
||||
|
|
@ -18,7 +19,7 @@ vi.mock("@/hooks/useMemoQueries", () => ({
|
|||
fetchNextPage: feed.fetchNextPage,
|
||||
hasNextPage: feed.hasNextPage,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
isLoading: feed.isLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ describe("<PagedMemoList>", () => {
|
|||
view.compactMode = false;
|
||||
feed.memos = [];
|
||||
feed.hasNextPage = false;
|
||||
feed.isLoading = false;
|
||||
feed.fetchNextPage.mockClear();
|
||||
readiness.auth = true;
|
||||
readiness.instance = true;
|
||||
|
|
@ -100,6 +102,22 @@ describe("<PagedMemoList>", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("delays the initial loading spinner to avoid flashing on fast loads", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
feed.isLoading = true;
|
||||
const { container } = renderList();
|
||||
|
||||
expect(container.querySelector(".animate-spin")).not.toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(249));
|
||||
expect(container.querySelector(".animate-spin")).not.toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1));
|
||||
expect(container.querySelector(".animate-spin")).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the tile sprite Placeholder for the empty state", () => {
|
||||
renderList();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue