test(release): cover 0.30.0 changelog promises

This commit is contained in:
johnnyjoygh 2026-07-26 22:21:19 +08:00
parent 8054cd536d
commit c536434b81
14 changed files with 646 additions and 12 deletions

View 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)
})
}

View 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))
}

View file

@ -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) {

View 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)))
})
}

View file

@ -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")
}

View file

@ -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");
});

View 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,
}),
);
});
});

View 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" });
});
});

View file

@ -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");
});
});

View 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");
});
});

View 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);
});
});

View 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");
});
});

View 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();
});
});

View file

@ -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();