T⇄ Test Chéo
Cộng đồng test app Android qua lại
Đã nối Firebase thật: đăng nhập Google và dữ liệu (app, lượt test, bài viết, thông báo, tin nhắn riêng, hạn mức) chạy trên dự án Firebase “Test Cheo”. Mọi con số trên trang đều tính từ dữ liệu thật trên Firestore — cộng đồng chưa có dữ liệu thì các số là 0 và các mục hiện trạng thái trống. Mã QR mở khóa PayOS vẫn đang chạy mô phỏng như trước.
Thứ Bảy · 14 tháng 11, 2026 · Số đầu tiên

Qua cửa 12 tester, không ai phải đi một mình

Google Play bắt app mới phải có 12 người test liên tục 14 ngày mới được phát hành. Ở Test Chéo, bạn test app của người khác — họ test lại app của bạn. Mọi lượt test đều công khai, có qua có lại rõ ràng, ai nợ ai nhìn là thấy ngay.

Nhận test ngay ↓
0Lập trình viên tham gia
0App đang được test
0Lượt test qua lại
0App đã qua cửa 12 tester

Trang nhất — App ưu tiên

Cách đưa app lên trang nhất →

Chợ test

Nhận test app của người khác — họ sẽ test lại app của bạn

Bảng tin cộng đồng

Tìm tester, hỏi đáp, chia sẻ kinh nghiệm qua cửa Play

Ủng hộ & mở khóa

Tiền ủng hộ để duy trì trang — quét QR là tự mở khóa, không cần ai duyệt tay

Miễn phí
0đ
  • 2 app đầu tiên đăng thoải mái, không mất gì
  • App hiện ở chợ test theo thứ tự mới nhất
  • Đủ bảng test qua lại và điểm uy tín công khai
Thêm slot đăng app
10.000đ / 1 app
  • Từ app thứ 3 trở đi, mỗi app ủng hộ 10.000đ
  • Khoản nhỏ để chặn đăng app rác, ai nghiêm túc đều không ngại
  • Slot mở vĩnh viễn cho app đó, không thu theo tháng
★ Ưu tiên trang nhất
20.000đ / 3 ngày
  • App lên khu Trang nhất ở đầu trang chủ, thẻ to và nổi bật nhất
  • Tối đa 5 chỗ cùng lúc — hết chỗ thì tự xếp hàng chờ
  • Đủ 12 tester trước hạn thì app tự nhường chỗ cho app sau

Cách mở khóa bằng mã QR nằm trong trang cá nhân của bạn, ở mục App của tôi & hạn mức.

← Về trang chủ
M

Trang cá nhân của bạn

Hồ sơ công khai — ai trong cộng đồng cũng xem được trang này của bạn
Độ có qua có lại0%

Bảng test qua lại của tôi

Ai đã test app của bạn — và bạn đã test lại cho họ chưa

Người đã testApp của bạn họ đã testBạn test lại chưa?

Lượt test chờ bạn xác nhận

Các lượt bạn đã test nhưng chưa ấn xác nhận

App của tôi

Bạn đang dùng 0/2 slot miễn phí

Ủng hộ & mở khóa bằng mã QR

2 app đầu miễn phí · thêm slot 10.000đ / app · đưa lên trang nhất 20.000đ / 3 ngày. Mọi lần mở khóa đều làm ngay tại đây, trong trang cá nhân của bạn:

1
Bấm nút mở khóa

Ngay trong form đăng app hoặc trang quản lý app của bạn — không phải đi đâu khác.

2
Hiện mã QR riêng

Mỗi lần ủng hộ là một mã VietQR riêng, đúng số tiền và đúng nội dung của bạn.

3
Quét bằng app ngân hàng / MoMo

Chuyển khoản như bình thường, tiền về thẳng tài khoản của trang qua cổng PayOS.

4
Tự mở khóa ngay

PayOS báo về trong vài giây — slot mở, app lên trang nhất luôn, không cần đợi ai xác nhận.

🔐 Firebase & quy tắc bảo mật Firestore (dành cho quản trị)

Trang này đã nối Firebase thật: đăng nhập bằng Google qua Firebase Authentication; dữ liệu — người dùng (kèm số lượt lên trang nhất đã trả tiền), app (kèm link test, số tester đã xác nhận, mốc đủ 12 tester, trạng thái đóng test, lịch sử các đợt test và trạng thái trang nhất / hàng chờ của từng app), lượt test, bảng test qua lại, bài viết, bình luận, thông báo, cuộc trò chuyện và tin nhắn riêng, hạn mức và slot trang nhất — nằm ở Cloud Firestore của dự án Test Cheo (test-cheo). Để Firestore cho phép đọc/ghi, hãy dán bộ quy tắc dưới đây vào Firebase Console → Firestore Database → Rules rồi bấm Publish. Chưa dán quy tắc thì Firestore từ chối truy cập và trang hiển thị trạng thái trống thật (mọi con số 0) cho đến khi đọc được dữ liệu. Cũng đừng quên thêm tên miền của trang vào Authentication → Settings → Authorized domains để cửa sổ đăng nhập Google hoạt động trên tên miền thật. Trang không tự nạp dữ liệu mẫu vào Firestore — cơ sở dữ liệu bắt đầu trống và chỉ chứa bản ghi do người dùng thật tạo ra. Email của người dùng không nằm trong hồ sơ công khai — nó ở bộ sưu tập riêng userPrivate mà chỉ chủ tài khoản mới đọc được.

// ============================================================================
// Test Chéo — Firestore rules v2 (CHƯA PUBLISH — xem hướng dẫn cuối file)
// Nguồn: rules v1 nguyên văn trong thẻ #firebase-rules của trang (15/11/2026),
// chỉ sửa 3 chỗ: (A) users update, (B) meta, (C) thêm collection orders.
// Mọi collection khác giữ nguyên v1.
// ----------------------------------------------------------------------------
// TẠI SAO KHÔNG CẤM HẲN freeUsed / extraSlots / featureCredit Ở CLIENT?
// persistUserDoc() của trang gộp các trường hạn mức vào MỌI lần ghi hồ sơ:
// đăng app miễn phí (freeUsed +1), dùng lượt trang nhất đã trả (featureCredit
// -1), hủy xếp hàng (featureCredit +1) đều ghi client-side. Cấm hẳn các
// trường này sẽ làm HỎNG đăng app miễn phí và luồng hàng chờ. Thay vào đó v2
// khóa theo BƯỚC NHẢY: mỗi lần ghi, mỗi bộ đếm chỉ được giữ nguyên hoặc +1
// (featureCredit ±1 vì có cả dùng và hoàn lượt), freeTotal không bao giờ đổi.
// Các lần mở khóa TRẢ PHÍ không còn phụ thuộc ghi client nữa: webhook PayOS
// (Worker, Admin SDK) mới là bên duy nhất cộng slot / xếp hàng trang nhất.
// ----------------------------------------------------------------------------
// LỖ HỔNG CÒN LẠI — ĐỌC KỸ TRƯỚC KHI PUBLISH:
//  (1) Rules không giới hạn tốc độ: một tài khoản vẫn có thể gọi API trực
//      tiếp để +1 từng lần cho bộ đếm. Bảo vệ thật cho tiền nằm ở chỗ: lượt
//      trả phí chỉ được cấp qua webhook PayOS đã xác minh chữ ký. Muốn khóa
//      hẳn thì phải dời MỌI lần đổi hạn mức sang server (Worker endpoints cho
//      cả đăng app miễn phí / dùng lượt / hủy hàng chờ) — chưa làm ở đợt này.
//  (2) meta/featured chỉ khóa theo HÌNH DẠNG (slots là list ≤ 5). Một tài
//      khoản vẫn có thể ghi một mảng slots đúng hình dạng để tự đưa app mình
//      lên trang nhất. Khóa hẳn cần UID admin: tạo một tài khoản admin, ghi
//      UID của nó vào rule (chỉ UID đó được ghi meta/featured) VÀ dời thao
//      tác dọn slot / thăng hạng của sweep sang server. Sweep hiện vẫn ghi
//      meta/featured client-side nên rule admin-only sẽ làm hỏng thăng hạng
//      hôm nay — vì vậy v2 chưa khóa hẳn, chỉ khóa hình dạng.
//  (3) Hạn mức lúc ĐĂNG app (freeUsed < freeTotal) vẫn chỉ do logic trang
//      kiểm tra; rules không kiểm tra ở apps/{id} create. Cùng lý do như (1).
// ----------------------------------------------------------------------------
// HƯỚNG DẪN PUBLISH (người giữ Firebase Console làm — KHÔNG tự publish bằng
// script): Firebase Console → Firestore Database → Rules → dán toàn bộ file
// này → Publish. Chỉ publish SAU KHI webhook PayOS đã xác nhận chạy được
// end-to-end (đã test 1 lần thanh toán 10k thật mở khóa đúng). Đồng thời cập
// nhật thẻ #firebase-rules trong trang bằng đúng nội dung file này (đã làm
// trong lần sửa web nối PayOS).
// ============================================================================

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() { return request.auth != null; }

    // Cổng nạp dữ liệu mẫu ĐÚNG MỘT LẦN: chỉ mở khi chưa có tài liệu
    // meta/seed và bản ghi gắn cờ seed == true; nạp xong cổng tự đóng hẳn.
    function seedOpen() {
      return !exists(/databases/$(database)/documents/meta/seed);
    }
    function isSeed() {
      return seedOpen() && request.resource.data.seed == true;
    }

    // Giá trị cũ của một bộ đếm (mặc định 0 nếu tài liệu cũ chưa có trường).
    function oldInt(field) {
      return field in resource.data && resource.data[field] is int ? resource.data[field] : 0;
    }
    // Một bộ đếm trong lần ghi này: hoặc không đụng tới, hoặc là số nguyên
    // nằm trong [cũ + minDelta, cũ + maxDelta].
    function counterStep(field, minDelta, maxDelta) {
      return !(field in request.resource.data.diff(resource.data).affectedKeys())
        || (request.resource.data[field] is int
            && request.resource.data[field] >= oldInt(field) + minDelta
            && request.resource.data[field] <= oldInt(field) + maxDelta);
    }
    // Một trường không được đổi giá trị trong lần ghi này.
    function fieldUnchanged(field) {
      return !(field in request.resource.data.diff(resource.data).affectedKeys());
    }

    // Hồ sơ người dùng CÔNG KHAI: ai cũng xem được tên, ảnh, điểm số —
    // tài liệu này TUYỆT ĐỐI không chứa email hay thông tin riêng.
    // Chỉ chủ tài khoản mới sửa được hồ sơ của mình; riêng lần nạp dữ
    // liệu mẫu đầu tiên được tạo thêm các hồ sơ mẫu qua cổng isSeed().
    // [v2] Update bị khóa: chỉ các trường hồ sơ an toàn + các bộ đếm với
    // bước nhảy cho phép (xem ghi chú đầu file). freeUsed, extraSlots,
    // featureCredit, given, received KHÔNG được đặt giá trị tùy ý;
    // freeTotal không bao giờ được đổi. Worker (Admin SDK) bỏ qua rule này.
    match /users/{uid} {
      allow read: if true;
      allow create: if signedIn() && (request.auth.uid == uid || isSeed());
      allow update: if signedIn() && request.auth.uid == uid
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(
             ['name', 'displayName', 'photoURL', 'bio', 'avatarKind', 'avatarIcon',
              'freeTotal', 'freeUsed', 'extraSlots', 'featureCredit', 'given', 'received'])
        && fieldUnchanged('freeTotal')
        && counterStep('freeUsed', 0, 1)
        && counterStep('extraSlots', 0, 1)
        && counterStep('featureCredit', -1, 1)
        && counterStep('given', 0, 1)
        && counterStep('received', 0, 1);
      allow delete: if false;
    }

    // Thông tin riêng của từng tài khoản (email…): chỉ chủ tài khoản đọc / ghi.
    match /userPrivate/{uid} {
      allow read, write: if signedIn() && request.auth.uid == uid;
    }

    // App ở chợ test: đọc công khai; chỉ chủ app mới đăng / sửa / xóa app của mình.
    // Ngoài các trường cũ, tài liệu app còn có: link (link test Play của app —
    // trang dùng nó để nhận ra app trùng khi chủ app dán lại link sau khi cập
    // nhật), confirmed (số tester ĐÃ ẤN XÁC NHẬN ở đợt hiện tại — đủ 12
    // confirmed là app đạt mốc "Đủ 12 tester"), fullAt (thời điểm người thứ
    // 12 xác nhận; ngày đủ điều kiện nộp đơn production = fullAt + 14 ngày),
    // closed / closedAt (chủ app tự đóng test sau khi nộp đơn xong) và rounds
    // (lịch sử các đợt test: mỗi phần tử gồm n, startedAt, confirmed, fullAt,
    // carryTotal, carryConfirmed). Chỉ CHỦ app mới sửa được link và closed
    // (mở đợt test mới, đóng test). Người khác được cập nhật testers (khi
    // nhận test / nhận dự phòng), confirmed, fullAt, rounds và bốn trường
    // của hàng chờ trang nhất (featuredStatus, queuedAt, featuredAt,
    // featuredUntil) — nhưng rounds
    // ở đây chỉ là các SỐ ĐẾM trong đợt hiện tại (confirmed, fullAt của phần
    // tử đợt đang chạy) mà trang ghi kèm mỗi lần ấn xác nhận đã cài &
    // dùng thử hoặc xác nhận lại ở đợt mới; người khác vẫn KHÔNG đụng được
    // vào link hay trạng thái đóng test của app. Bốn trường featuredStatus,
    // queuedAt, featuredAt, featuredUntil phục vụ hàng chờ trang nhất: khi
    // một app hết 3 ngày hoặc đủ 12 tester, trang tự dọn chỗ của nó rồi
    // thăng hạng app đang xếp hàng đầu tiên — thao tác đó ghi vào tài liệu
    // app của NGƯỜI KHÁC nên bốn trường này phải nằm trong danh sách được
    // phép cập nhật ở dưới.
    match /apps/{appId} {
      allow read: if true;
      allow create: if signedIn() && (request.resource.data.ownerUid == request.auth.uid || isSeed());
      allow update: if signedIn() && (
        resource.data.ownerUid == request.auth.uid ||
        request.resource.data.diff(resource.data).affectedKeys().hasOnly(['testers', 'confirmed', 'fullAt', 'rounds', 'featuredStatus', 'queuedAt', 'featuredAt', 'featuredUntil'])
      );
      allow delete: if signedIn() && resource.data.ownerUid == request.auth.uid;
    }

    // Lượt test & bảng qua lại: phải đăng nhập mới xem được;
    // người test tạo lượt của mình, hai bên liên quan được cập nhật.
    match /tests/{testId} {
      allow read: if signedIn();
      allow create: if signedIn() && (request.resource.data.testerUid == request.auth.uid || isSeed());
      allow update: if signedIn() && (resource.data.testerUid == request.auth.uid || resource.data.ownerUid == request.auth.uid);
      allow delete: if false;
    }

    // Bài viết cộng đồng: đọc công khai; đăng bài phải gắn đúng tài khoản của mình;
    // người đã đăng nhập được thích & bình luận (likes/comments nằm trong cùng tài liệu).
    match /posts/{postId} {
      allow read: if true;
      allow create: if signedIn() && (request.resource.data.authorUid == request.auth.uid || isSeed());
      allow update: if signedIn();
      allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
    }

    // Thông báo: chỉ chủ sở hữu mới đọc / sửa / xóa được thông báo của mình.
    // Tạo mới phải đúng khuôn dữ liệu của trang: icon là emoji ngắn (tối đa
    // 6 ký tự, không chứa mã HTML), tiêu đề và nội dung xem trước có giới hạn.
    match /notifications/{notifId} {
      allow read, update, delete: if signedIn() && resource.data.ownerUid == request.auth.uid;
      allow create: if signedIn()
        && request.resource.data.keys().hasOnly(['ownerUid', 'type', 'icon', 'title', 'preview', 'time', 'read', 'target', 'createdAt'])
        && request.resource.data.icon is string && request.resource.data.icon.size() <= 6
        && request.resource.data.title is string && request.resource.data.title.size() <= 200
        && request.resource.data.preview is string && request.resource.data.preview.size() <= 500;
    }

    // Cuộc trò chuyện riêng 1-1: chỉ hai người có uid nằm trong mảng
    // participants mới đọc / cập nhật được đoạn chat của mình.
    match /conversations/{convId} {
      allow read: if signedIn() && request.auth.uid in resource.data.participants;
      allow create: if signedIn()
        && (request.auth.uid in request.resource.data.participants || isSeed())
        && request.resource.data.keys().hasOnly(['participants', 'aUid', 'aName', 'bUid', 'bName', 'appName', 'testApp', 'lastText', 'lastAt', 'createdAt', 'seed']);
      allow update: if signedIn() && request.auth.uid in resource.data.participants;
      allow delete: if false;
    }

    // Tin nhắn riêng: CHỈ gồm chữ, emoji và nhãn dán — trang KHÔNG gửi ảnh
    // nên tài liệu tin nhắn bị khóa đúng các trường dưới đây (không chèn
    // được trường ảnh/dữ liệu lạ), chữ tối đa 2000 ký tự, nhãn dán tối đa
    // 8 ký tự. Chỉ người trong cuộc trò chuyện mới đọc được; người gửi
    // phải là một bên của cuộc trò chuyện; người trong cuộc chỉ được sửa
    // duy nhất trường reactions để thả cảm xúc vào tin nhắn.
    match /messages/{msgId} {
      function inConv(cid) {
        return signedIn() && request.auth.uid in
          get(/databases/$(database)/documents/conversations/$(cid)).data.participants;
      }
      allow read: if inConv(resource.data.convId);
      allow create: if signedIn()
        && request.resource.data.keys().hasOnly(['convId', 'senderUid', 'senderName', 'text', 'sticker', 'reactions', 'createdAt', 'seed'])
        && request.resource.data.text is string && request.resource.data.text.size() <= 2000
        && (request.resource.data.sticker == null || (request.resource.data.sticker is string && request.resource.data.sticker.size() <= 8))
        && ((request.resource.data.senderUid == request.auth.uid && inConv(request.resource.data.convId)) || isSeed());
      allow update: if inConv(resource.data.convId)
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions']);
      allow delete: if false;
    }

    // [v2] Đơn thanh toán PayOS: chỉ Worker (Admin SDK, bỏ qua quy tắc) mới
    // tạo / cập nhật. Người dùng chỉ được đọc đơn của chính mình. Trang
    // không đọc trực tiếp collection này mà hỏi Worker qua /order-status.
    match /orders/{orderCode} {
      allow read: if signedIn() && resource.data.userId == request.auth.uid;
      allow create, update, delete: if false;
    }

    // Dữ liệu chung (slot trang nhất, mốc đã nạp dữ liệu mẫu): đọc công khai.
    // [v2] Ghi bị khóa theo hình dạng (v1 cho mọi tài khoản đã đăng nhập ghi
    // tùy ý — xem lỗ hổng (2) ở đầu file). Chỉ được thay mảng slots của
    // meta/featured (tối đa 5 chỗ) để thao tác dọn slot / thăng hạng của
    // trang vẫn chạy; tài liệu meta/seed chỉ được tạo một lần (đóng cổng
    // seed), không sửa / xóa.
    match /meta/{docId} {
      allow read: if true;
      allow create: if signedIn() && (
        (docId == 'featured'
         && request.resource.data.slots is list
         && request.resource.data.slots.size() <= 5)
        || docId == 'seed'
      );
      allow update: if signedIn() && docId == 'featured'
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['slots'])
        && request.resource.data.slots is list
        && request.resource.data.slots.size() <= 5;
      allow delete: if false;
    }
  }
}

// Ghi chú (giữ lại từ v1, cập nhật cho v2):
// - Về ảnh trong tin nhắn: nhắn tin riêng hiện chỉ gồm chữ, emoji và nhãn
//   dán — trang KHÔNG dùng Firebase Storage (dự án đang ở gói Spark miễn
//   phí, bật Storage phải nâng cấp gói) nên KHÔNG cần bộ quy tắc Storage nào
//   cả. Quy tắc của bộ sưu tập messages ở trên đã khóa đúng các trường được
//   phép và giới hạn độ dài, nên không ai chèn được dữ liệu ảnh vào tài liệu
//   tin nhắn.
// - Về PayOS thật (v2): việc mở slot và đưa app lên trang nhất do webhook
//   PayOS ghi từ phía máy chủ (Cloudflare Worker, Admin SDK bỏ qua quy tắc
//   này) — xem ~/workspace/test-cheo/worker/. Quy tắc users ở trên khóa các
//   trường hạn mức theo bước nhảy, và meta/featured khóa theo hình dạng
//   slots; các lỗ hổng còn lại ghi ở đầu file.

Về ảnh trong tin nhắn: nhắn tin riêng hiện chỉ gồm chữ, emoji và nhãn dán — trang không dùng Firebase Storage (dự án đang ở gói Spark miễn phí, bật Storage phải nâng cấp gói) nên không cần bộ quy tắc Storage nào cả. Quy tắc của bộ sưu tập messages ở trên đã khóa đúng các trường được phép và giới hạn độ dài, nên không ai chèn được dữ liệu ảnh vào tài liệu tin nhắn.

Lưu ý khi nối PayOS thật sau này: việc mở slot và đưa app lên trang nhất phải do webhook PayOS ghi từ phía máy chủ (Admin SDK bỏ qua quy tắc này) — khi đó hãy siết thêm quy tắc để người dùng không tự sửa được các trường hạn mức (freeUsed, extraSlots) trong hồ sơ của mình.

T

Trang cá nhân

Hồ sơ công khai — xem được app của họ, họ đã test cho ai và còn nợ test ai
0App đã đăng
0Lượt được test
0Lượt đã test cho người khác
0Đang nợ test lại
Độ có qua có lại0%

App của họ

Bấm Nhận test để test app của người này — họ sẽ thấy bạn trong bảng qua lại của họ

Đã test & đang nợ test

Công khai để ai cũng biết người này có qua có lại thế nào

Tin nhắn riêng

Nhắn 1-1 với người test và chủ app — trao đổi thêm ngoài bình luận công khai. Chỉ hai người trong cuộc trò chuyện mới đọc được.